fix(nlp): add EPUB-specific text splitting for TTS blocks
Introduce `splitTextToTtsBlocksEPUB` function to handle EPUB text splitting, treating single newlines as paragraph boundaries for precise highlighting. Update TTS context to conditionally use EPUB splitting based on document type. Enhance PDFViewer to clear highlights when current sentence is null. Add comprehensive tests for the new functionality and refactor existing ones.
This commit is contained in:
parent
63c316fc4b
commit
efe6ffec86
4 changed files with 222 additions and 118 deletions
|
|
@ -152,7 +152,7 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (currentWordIndex === null || currentWordIndex === undefined || currentWordIndex < 0) {
|
||||
if (!currentSentence || currentWordIndex === null || currentWordIndex === undefined || currentWordIndex < 0) {
|
||||
clearWordHighlights();
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ import { useAudioContext } from '@/hooks/audio/useAudioContext';
|
|||
import { getLastDocumentLocation, setLastDocumentLocation } from '@/lib/dexie';
|
||||
import { useBackgroundState } from '@/hooks/audio/useBackgroundState';
|
||||
import { withRetry, generateTTS, alignAudio } from '@/lib/client';
|
||||
import { preprocessSentenceForAudio, splitTextToTtsBlocks } from '@/lib/nlp';
|
||||
import { preprocessSentenceForAudio, splitTextToTtsBlocks, splitTextToTtsBlocksEPUB } from '@/lib/nlp';
|
||||
import { isKokoroModel } from '@/utils/voice';
|
||||
import type {
|
||||
TTSLocation,
|
||||
|
|
@ -385,8 +385,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
}
|
||||
|
||||
// Use the shared utility directly instead of making an API call
|
||||
return splitTextToTtsBlocks(text);
|
||||
}, []);
|
||||
return isEPUB ? splitTextToTtsBlocksEPUB(text) : splitTextToTtsBlocks(text);
|
||||
}, [isEPUB]);
|
||||
|
||||
/**
|
||||
* Stops the current audio playback and clears the active Howl instance
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import nlp from 'compromise';
|
||||
|
||||
const MAX_BLOCK_LENGTH = 450;
|
||||
export const MAX_BLOCK_LENGTH = 450;
|
||||
|
||||
const splitOversizedText = (text: string, maxLen: number): string[] => {
|
||||
const normalized = text.replace(/\s+/g, ' ').trim();
|
||||
|
|
@ -182,6 +182,46 @@ export const splitTextToTtsBlocks = (text: string): string[] => {
|
|||
return blocks;
|
||||
};
|
||||
|
||||
/**
|
||||
* EPUB block splitting used where we want the produced sentences
|
||||
* to closely match the original DOM text (for exact-match highlighting).
|
||||
*/
|
||||
export const splitTextToTtsBlocksEPUB = (text: string): string[] => {
|
||||
const paragraphs = text.split(/\n+/);
|
||||
const blocks: string[] = [];
|
||||
|
||||
for (const paragraph of paragraphs) {
|
||||
if (!paragraph.trim()) continue;
|
||||
|
||||
const cleanedText = preprocessSentenceForAudio(paragraph);
|
||||
const doc = nlp(cleanedText);
|
||||
const rawSentences = doc.sentences().out('array') as string[];
|
||||
|
||||
const mergedSentences = mergeQuotedDialogue(rawSentences);
|
||||
|
||||
let currentBlock = '';
|
||||
|
||||
for (const sentence of mergedSentences) {
|
||||
const trimmedSentence = sentence.trim();
|
||||
|
||||
if (currentBlock && (currentBlock.length + trimmedSentence.length + 1) > MAX_BLOCK_LENGTH) {
|
||||
blocks.push(currentBlock.trim());
|
||||
currentBlock = trimmedSentence;
|
||||
} else {
|
||||
currentBlock = currentBlock
|
||||
? `${currentBlock} ${trimmedSentence}`
|
||||
: trimmedSentence;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentBlock) {
|
||||
blocks.push(currentBlock.trim());
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes text for single-shot TTS generation (e.g., a whole PDF page).
|
||||
* Uses the same logic as `splitTextToTtsBlocks`, but returns a single string.
|
||||
|
|
|
|||
|
|
@ -2,150 +2,214 @@ import { test, expect } from '@playwright/test';
|
|||
import {
|
||||
preprocessSentenceForAudio,
|
||||
splitTextToTtsBlocks,
|
||||
processTextWithMapping
|
||||
splitTextToTtsBlocksEPUB,
|
||||
normalizeTextForTts,
|
||||
extractRawSentences,
|
||||
processTextWithMapping,
|
||||
MAX_BLOCK_LENGTH
|
||||
} from '../../src/lib/nlp';
|
||||
|
||||
const PDF_MAX_BLOCK_LENGTH = MAX_BLOCK_LENGTH * 2; // splitTextToTtsBlocks can overflow to reach punctuation
|
||||
|
||||
const expectNormalizedBlocks = (blocks: string[], maxLen = Number.POSITIVE_INFINITY) => {
|
||||
for (const block of blocks) {
|
||||
expect(block.trim().length).toBeGreaterThan(0);
|
||||
expect(block.length).toBeLessThanOrEqual(maxLen);
|
||||
expect(block).not.toMatch(/\n/);
|
||||
expect(block).not.toMatch(/\s{2,}/);
|
||||
}
|
||||
};
|
||||
|
||||
test.describe('preprocessSentenceForAudio', () => {
|
||||
test('removes URLs', () => {
|
||||
const input = 'Check out https://example.com/page for more info';
|
||||
const expected = 'Check out - (link to example.com) - for more info';
|
||||
expect(preprocessSentenceForAudio(input)).toBe(expected);
|
||||
});
|
||||
test('normalizes common extraction artifacts', () => {
|
||||
const cases: Array<{ input: string; expected: string }> = [
|
||||
{
|
||||
input: 'Check out https://example.com/page for more info',
|
||||
expected: 'Check out - (link to example.com) - for more info',
|
||||
},
|
||||
{
|
||||
input: 'This is a hyp- henated word',
|
||||
expected: 'This is a hyphenated word',
|
||||
},
|
||||
{
|
||||
input: 'This is *bold* text',
|
||||
expected: 'This is bold text',
|
||||
},
|
||||
{
|
||||
input: 'Multiple spaces',
|
||||
expected: 'Multiple spaces',
|
||||
},
|
||||
];
|
||||
|
||||
test('removes hyphenation', () => {
|
||||
const input = 'This is a hyp- henated word';
|
||||
const expected = 'This is a hyphenated word';
|
||||
expect(preprocessSentenceForAudio(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test('removes asterisks', () => {
|
||||
const input = 'This is *bold* text';
|
||||
const expected = 'This is bold text';
|
||||
expect(preprocessSentenceForAudio(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test('collapses whitespace', () => {
|
||||
const input = 'Multiple spaces';
|
||||
const expected = 'Multiple spaces';
|
||||
expect(preprocessSentenceForAudio(input)).toBe(expected);
|
||||
for (const { input, expected } of cases) {
|
||||
expect(preprocessSentenceForAudio(input)).toBe(expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('splitTextToTtsBlocks', () => {
|
||||
test('groups short sentences into single block', () => {
|
||||
const input = 'First sentence. Second sentence.';
|
||||
const result = splitTextToTtsBlocks(input);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe('First sentence. Second sentence.');
|
||||
});
|
||||
|
||||
test('merges quoted dialogue (double quotes)', () => {
|
||||
const input = 'He said, "This should be one block." and walked away.';
|
||||
const result = splitTextToTtsBlocks(input);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe('He said, "This should be one block." and walked away.');
|
||||
});
|
||||
|
||||
test('merges quoted dialogue (curly quotes)', () => {
|
||||
const input = 'She replied, “This also should be merged.” then smiled.';
|
||||
const result = splitTextToTtsBlocks(input);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe('She replied, “This also should be merged.” then smiled.');
|
||||
});
|
||||
|
||||
test('respects max block length for long text', () => {
|
||||
// MAX_BLOCK_LENGTH is 450 in nlp.ts
|
||||
// We construct distinct sentences.
|
||||
// If we make sentences short enough individually but long enough combined,
|
||||
// they should be grouped until the limit is reached.
|
||||
|
||||
const sentence = 'A'.repeat(100) + '.'; // 101 chars
|
||||
// 4 sentences = 404 chars + 3 spaces = 407 chars (< 450). Should fit in one block.
|
||||
// 5 sentences = 505 chars + 4 spaces = 509 chars (> 450). Should split.
|
||||
|
||||
const input = Array(5).fill(sentence).join(' ');
|
||||
const result = splitTextToTtsBlocks(input);
|
||||
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
// The first block should contain as many as possible
|
||||
expect(result[0].length).toBeLessThanOrEqual(450);
|
||||
});
|
||||
|
||||
test('splits a single oversized sentence into multiple blocks', () => {
|
||||
// Some PDF pages (e.g. research papers) can extract into one massive "sentence"
|
||||
// with few or no punctuation marks; we still must respect MAX_BLOCK_LENGTH.
|
||||
const input = Array(1200).fill('word').join(' '); // no punctuation
|
||||
const result = splitTextToTtsBlocks(input);
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
for (const block of result) {
|
||||
expect(block.length).toBeGreaterThan(0);
|
||||
expect(block.length).toBeLessThanOrEqual(450);
|
||||
}
|
||||
});
|
||||
|
||||
test('splits extremely long unbroken tokens', () => {
|
||||
const input = 'A'.repeat(1200); // no spaces, no punctuation
|
||||
const result = splitTextToTtsBlocks(input);
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
for (const block of result) {
|
||||
expect(block.length).toBeGreaterThan(0);
|
||||
expect(block.length).toBeLessThanOrEqual(450);
|
||||
}
|
||||
});
|
||||
|
||||
test('prefers sentence punctuation when chunking long PDF-like text', () => {
|
||||
const sentences = Array.from({ length: 80 }, (_, i) =>
|
||||
`Sentence ${i} has some filler words to keep the length varying slightly number ${i}.`
|
||||
);
|
||||
// Simulate a common PDF extraction artifact: no whitespace after '.' before the next sentence.
|
||||
const input = sentences.join('');
|
||||
const result = splitTextToTtsBlocks(input);
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
for (const block of result) {
|
||||
expect(block.length).toBeGreaterThan(0);
|
||||
expect(block.length).toBeLessThanOrEqual(450);
|
||||
expect(block).toMatch(/[.!?]$/);
|
||||
}
|
||||
test.describe('splitTextToTtsBlocks (PDF-oriented)', () => {
|
||||
test('returns [] for empty input', () => {
|
||||
expect(splitTextToTtsBlocks('')).toEqual([]);
|
||||
expect(splitTextToTtsBlocks(' ')).toEqual([]);
|
||||
expect(splitTextToTtsBlocks('\n\n')).toEqual([]);
|
||||
});
|
||||
|
||||
test('does not treat single newlines as paragraph boundaries', () => {
|
||||
// Many PDFs contain hard-wrapped lines; we should not break blocks/sentences
|
||||
// just because of a newline.
|
||||
const input =
|
||||
'The first line ends with a comma,\n' +
|
||||
'but the sentence continues on the next line and ends here.\n' +
|
||||
'And this is the second sentence.';
|
||||
const result = splitTextToTtsBlocks(input);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expectNormalizedBlocks(result, PDF_MAX_BLOCK_LENGTH);
|
||||
expect(result[0]).toBe(
|
||||
'The first line ends with a comma, but the sentence continues on the next line and ends here. And this is the second sentence.'
|
||||
);
|
||||
});
|
||||
|
||||
test('allows long sentences to reach their ending punctuation', () => {
|
||||
const longSentence =
|
||||
`${'word '.repeat(110)}` + // ~550 chars before period
|
||||
'end.' +
|
||||
' Next.';
|
||||
const result = splitTextToTtsBlocks(longSentence);
|
||||
// The first block should end at a period, not be cut mid-sentence at a space boundary.
|
||||
test('treats blank lines (double newlines) as paragraph boundaries', () => {
|
||||
const input = 'First paragraph.\n\nSecond paragraph.';
|
||||
const result = splitTextToTtsBlocks(input);
|
||||
|
||||
expect(result.length).toBeGreaterThanOrEqual(2);
|
||||
expectNormalizedBlocks(result, PDF_MAX_BLOCK_LENGTH);
|
||||
});
|
||||
|
||||
test('repairs missing whitespace between sentences (common PDF artifact)', () => {
|
||||
const input = 'This ends.Here starts.';
|
||||
const normalized = normalizeTextForTts(input);
|
||||
expect(normalized).toContain('ends. Here');
|
||||
});
|
||||
|
||||
test('does not break decimals when repairing sentence boundaries', () => {
|
||||
const input = 'Pi is 3.14.Next sentence.';
|
||||
const normalized = normalizeTextForTts(input);
|
||||
expect(normalized).toContain('3.14');
|
||||
});
|
||||
|
||||
test('enforces max block length on long content', () => {
|
||||
const sentence = `${'A'.repeat(100)}.`; // 101 chars
|
||||
const input = Array(8).fill(sentence).join(' '); // guaranteed to exceed MAX_BLOCK_LENGTH
|
||||
const result = splitTextToTtsBlocks(input);
|
||||
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
expect(result[0].endsWith('.')).toBe(true);
|
||||
expect(result[0].includes('end.')).toBe(true);
|
||||
expectNormalizedBlocks(result, MAX_BLOCK_LENGTH);
|
||||
});
|
||||
|
||||
test('splits oversized content with no punctuation', () => {
|
||||
const input = Array(1200).fill('word').join(' ');
|
||||
const result = splitTextToTtsBlocks(input);
|
||||
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
expectNormalizedBlocks(result, MAX_BLOCK_LENGTH);
|
||||
});
|
||||
|
||||
test('splits extremely long unbroken tokens', () => {
|
||||
const input = 'A'.repeat(1200);
|
||||
const result = splitTextToTtsBlocks(input);
|
||||
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
expectNormalizedBlocks(result, MAX_BLOCK_LENGTH);
|
||||
});
|
||||
|
||||
test('prefers sentence punctuation when chunking long PDF-like text', () => {
|
||||
const sentences = Array.from(
|
||||
{ length: 80 },
|
||||
(_, i) => `Sentence ${i} has filler words to vary length slightly number ${i}.`
|
||||
);
|
||||
const input = sentences.join(''); // no whitespace after '.' between sentences
|
||||
const result = splitTextToTtsBlocks(input);
|
||||
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
expectNormalizedBlocks(result, PDF_MAX_BLOCK_LENGTH);
|
||||
|
||||
// When sentence punctuation exists, blocks should usually end at punctuation/closers.
|
||||
// This guards against regressions where we cut mid-word/mid-sentence too often.
|
||||
for (const block of result) {
|
||||
expect(block).toMatch(/[.!?]["'”’)\]]*$/);
|
||||
}
|
||||
});
|
||||
|
||||
test('allows a long sentence to extend to its ending punctuation', () => {
|
||||
// Create a single sentence that exceeds MAX_BLOCK_LENGTH, but ends with a period
|
||||
// within the forward-search overflow window.
|
||||
const input = `${'word '.repeat(110)}end. Next.`;
|
||||
const result = splitTextToTtsBlocks(input);
|
||||
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
// This case is specifically asserting we may exceed MAX_BLOCK_LENGTH to reach punctuation,
|
||||
// but should still remain bounded by the overflow policy.
|
||||
expectNormalizedBlocks(result, PDF_MAX_BLOCK_LENGTH);
|
||||
expect(result[0]).toMatch(/end\.$/);
|
||||
});
|
||||
|
||||
test('merges multi-sentence quoted dialogue', () => {
|
||||
const input = 'He said, "First. Second." Then left.';
|
||||
const result = splitTextToTtsBlocks(input);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expectNormalizedBlocks(result, PDF_MAX_BLOCK_LENGTH);
|
||||
expect(result[0]).toContain('"First. Second."');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('splitTextToTtsBlocksEPUB (highlight-friendly)', () => {
|
||||
test('returns [] for empty input', () => {
|
||||
expect(splitTextToTtsBlocksEPUB('')).toEqual([]);
|
||||
expect(splitTextToTtsBlocksEPUB(' ')).toEqual([]);
|
||||
expect(splitTextToTtsBlocksEPUB('\n\n')).toEqual([]);
|
||||
});
|
||||
|
||||
test('treats single newlines as paragraph boundaries', () => {
|
||||
const input = 'One.\nTwo.';
|
||||
const result = splitTextToTtsBlocksEPUB(input);
|
||||
expect(result).toHaveLength(2);
|
||||
expectNormalizedBlocks(result, MAX_BLOCK_LENGTH);
|
||||
expect(result[0]).toBe('One.');
|
||||
expect(result[1]).toBe('Two.');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('normalizeTextForTts', () => {
|
||||
test('returns a single normalized string without newlines', () => {
|
||||
const input = 'Hello.\nWorld.\n\nNext paragraph.';
|
||||
const normalized = normalizeTextForTts(input);
|
||||
expect(normalized).not.toMatch(/\n/);
|
||||
expect(normalized).not.toMatch(/\s{2,}/);
|
||||
expect(normalized.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('extractRawSentences', () => {
|
||||
test('returns [] for empty input', () => {
|
||||
expect(extractRawSentences('')).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns sentence-like strings without preprocessing', () => {
|
||||
const input = 'First sentence. Second sentence.';
|
||||
const result = extractRawSentences(input);
|
||||
expect(result.length).toBeGreaterThanOrEqual(2);
|
||||
expect(result[0]).toContain('First');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('processTextWithMapping', () => {
|
||||
test('maps raw sentences to processed ones', () => {
|
||||
test('returns mapping entries with valid indices', () => {
|
||||
const text = 'First (1). Second (2).';
|
||||
const { processedSentences, rawSentences, sentenceMapping } = processTextWithMapping(text);
|
||||
|
||||
expect(processedSentences.length).toBeGreaterThan(0);
|
||||
expect(rawSentences.length).toBeGreaterThan(0);
|
||||
expect(sentenceMapping).toHaveLength(processedSentences.length);
|
||||
|
||||
// Check structure of mapping
|
||||
expect(sentenceMapping[0]).toHaveProperty('processedIndex');
|
||||
expect(sentenceMapping[0]).toHaveProperty('rawIndices');
|
||||
|
||||
for (let i = 0; i < sentenceMapping.length; i++) {
|
||||
const entry = sentenceMapping[i];
|
||||
expect(entry.processedIndex).toBe(i);
|
||||
for (const rawIndex of entry.rawIndices) {
|
||||
expect(rawIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(rawIndex).toBeLessThan(rawSentences.length);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue