refactor(nlp): improve text splitting and normalization for TTS blocks
- Rename and enhance text processing functions in nlp.ts for better handling of oversized texts, sentence boundaries, and PDF artifacts - Update PDFViewer to add layout-aware highlighting with retry logic for sentence and word highlights - Adjust PDFContext and TTSContext to use new normalized text functions - Expand unit tests for new splitting behaviors, including long texts and punctuation preferences
This commit is contained in:
parent
54145e2550
commit
c28c074e43
5 changed files with 316 additions and 56 deletions
|
|
@ -24,6 +24,12 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const scaleRef = useRef<number>(1);
|
const scaleRef = useRef<number>(1);
|
||||||
const { containerWidth } = usePDFResize(containerRef);
|
const { containerWidth } = usePDFResize(containerRef);
|
||||||
|
const sentenceHighlightSeqRef = useRef(0);
|
||||||
|
const wordHighlightSeqRef = useRef(0);
|
||||||
|
const sentenceHighlightTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||||
|
const wordHighlightTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||||
|
const lastSentenceLayoutKeyRef = useRef<string>('');
|
||||||
|
const lastWordLayoutKeyRef = useRef<string>('');
|
||||||
|
|
||||||
// Config context
|
// Config context
|
||||||
const { viewType, pdfHighlightEnabled, pdfWordHighlightEnabled } = useConfig();
|
const { viewType, pdfHighlightEnabled, pdfWordHighlightEnabled } = useConfig();
|
||||||
|
|
@ -49,6 +55,28 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||||
currDocPage,
|
currDocPage,
|
||||||
} = usePDF();
|
} = usePDF();
|
||||||
|
|
||||||
|
const layoutKey = `${zoomLevel}:${containerWidth}:${viewType}:${currDocPage}`;
|
||||||
|
|
||||||
|
const clearSentenceHighlightTimeouts = useCallback(() => {
|
||||||
|
for (const t of sentenceHighlightTimeoutsRef.current) clearTimeout(t);
|
||||||
|
sentenceHighlightTimeoutsRef.current = [];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearWordHighlightTimeouts = useCallback(() => {
|
||||||
|
for (const t of wordHighlightTimeoutsRef.current) clearTimeout(t);
|
||||||
|
wordHighlightTimeoutsRef.current = [];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scheduleSentenceTimeout = useCallback((fn: () => void, ms: number) => {
|
||||||
|
const t = setTimeout(fn, ms);
|
||||||
|
sentenceHighlightTimeoutsRef.current.push(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scheduleWordTimeout = useCallback((fn: () => void, ms: number) => {
|
||||||
|
const t = setTimeout(fn, ms);
|
||||||
|
wordHighlightTimeoutsRef.current.push(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
/*
|
/*
|
||||||
* Handles highlighting the current sentence being read by TTS.
|
* Handles highlighting the current sentence being read by TTS.
|
||||||
|
|
@ -66,17 +94,47 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const highlightTimeout = setTimeout(() => {
|
clearSentenceHighlightTimeouts();
|
||||||
if (containerRef.current) {
|
|
||||||
highlightPattern(currDocText, currentSentence || '', containerRef as RefObject<HTMLDivElement>);
|
const seq = ++sentenceHighlightSeqRef.current;
|
||||||
|
const isLayoutChange = layoutKey !== lastSentenceLayoutKeyRef.current;
|
||||||
|
lastSentenceLayoutKeyRef.current = layoutKey;
|
||||||
|
|
||||||
|
if (isLayoutChange) {
|
||||||
|
clearHighlights();
|
||||||
|
}
|
||||||
|
|
||||||
|
const tryApply = (attempt: number) => {
|
||||||
|
if (seq !== sentenceHighlightSeqRef.current) return;
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
if (!currentSentence) return;
|
||||||
|
|
||||||
|
const spans = container.querySelectorAll('.react-pdf__Page__textContent span');
|
||||||
|
if (!spans.length) {
|
||||||
|
if (attempt < 10) scheduleSentenceTimeout(() => tryApply(attempt + 1), 75);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, 200);
|
|
||||||
|
highlightPattern(currDocText, currentSentence, containerRef as RefObject<HTMLDivElement>);
|
||||||
|
};
|
||||||
|
|
||||||
|
scheduleSentenceTimeout(() => tryApply(0), 200);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(highlightTimeout);
|
clearSentenceHighlightTimeouts();
|
||||||
clearHighlights();
|
clearHighlights();
|
||||||
};
|
};
|
||||||
}, [currDocText, currentSentence, highlightPattern, clearHighlights, pdfHighlightEnabled]);
|
}, [
|
||||||
|
currDocText,
|
||||||
|
currentSentence,
|
||||||
|
highlightPattern,
|
||||||
|
clearHighlights,
|
||||||
|
pdfHighlightEnabled,
|
||||||
|
layoutKey,
|
||||||
|
clearSentenceHighlightTimeouts,
|
||||||
|
scheduleSentenceTimeout
|
||||||
|
]);
|
||||||
|
|
||||||
// Word-level highlight layered on top of the block highlight
|
// Word-level highlight layered on top of the block highlight
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -102,12 +160,41 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
highlightWordIndex(
|
clearWordHighlightTimeouts();
|
||||||
currentSentenceAlignment,
|
|
||||||
currentWordIndex,
|
const seq = ++wordHighlightSeqRef.current;
|
||||||
currentSentence || '',
|
const isLayoutChange = layoutKey !== lastWordLayoutKeyRef.current;
|
||||||
containerRef as RefObject<HTMLDivElement>
|
lastWordLayoutKeyRef.current = layoutKey;
|
||||||
);
|
|
||||||
|
const tryApplyWord = (attempt: number) => {
|
||||||
|
if (seq !== wordHighlightSeqRef.current) return;
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
highlightWordIndex(
|
||||||
|
currentSentenceAlignment,
|
||||||
|
currentWordIndex,
|
||||||
|
currentSentence || '',
|
||||||
|
containerRef as RefObject<HTMLDivElement>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLayoutChange) {
|
||||||
|
// If we don't see a word overlay yet, the sentence highlight worker may not
|
||||||
|
// have produced `lastSentenceTokenWindow` (or the text layer isn't ready).
|
||||||
|
const overlayCount = container.querySelectorAll('.pdf-word-highlight-overlay').length;
|
||||||
|
if (overlayCount === 0 && attempt < 12) {
|
||||||
|
scheduleWordTimeout(() => tryApplyWord(attempt + 1), 75);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLayoutChange) {
|
||||||
|
clearWordHighlights();
|
||||||
|
scheduleWordTimeout(() => tryApplyWord(0), 250);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tryApplyWord(0);
|
||||||
}, [
|
}, [
|
||||||
currentWordIndex,
|
currentWordIndex,
|
||||||
currentSentence,
|
currentSentence,
|
||||||
|
|
@ -115,7 +202,10 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||||
pdfHighlightEnabled,
|
pdfHighlightEnabled,
|
||||||
pdfWordHighlightEnabled,
|
pdfWordHighlightEnabled,
|
||||||
clearWordHighlights,
|
clearWordHighlights,
|
||||||
highlightWordIndex
|
highlightWordIndex,
|
||||||
|
layoutKey,
|
||||||
|
clearWordHighlightTimeouts,
|
||||||
|
scheduleWordTimeout
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Add page dimensions state
|
// Add page dimensions state
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ import type { PDFDocumentProxy } from 'pdfjs-dist';
|
||||||
import { getPdfDocument } from '@/lib/dexie';
|
import { getPdfDocument } from '@/lib/dexie';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { processTextToSentences } from '@/lib/nlp';
|
import { normalizeTextForTts } from '@/lib/nlp';
|
||||||
import { withRetry, getAudiobookStatus, generateTTS, createAudiobookChapter } from '@/lib/client';
|
import { withRetry, getAudiobookStatus, generateTTS, createAudiobookChapter } from '@/lib/client';
|
||||||
import {
|
import {
|
||||||
extractTextFromPDF,
|
extractTextFromPDF,
|
||||||
|
|
@ -312,9 +312,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
});
|
});
|
||||||
const trimmedText = rawText.trim();
|
const trimmedText = rawText.trim();
|
||||||
if (trimmedText) {
|
if (trimmedText) {
|
||||||
const processedText = smartSentenceSplitting
|
const processedText = smartSentenceSplitting ? normalizeTextForTts(trimmedText) : trimmedText;
|
||||||
? processTextToSentences(trimmedText).join(' ')
|
|
||||||
: trimmedText;
|
|
||||||
|
|
||||||
textPerPage.push(processedText);
|
textPerPage.push(processedText);
|
||||||
totalLength += processedText.length;
|
totalLength += processedText.length;
|
||||||
|
|
@ -532,7 +530,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const textForTTS = smartSentenceSplitting
|
const textForTTS = smartSentenceSplitting
|
||||||
? processTextToSentences(trimmedText).join(' ')
|
? normalizeTextForTts(trimmedText)
|
||||||
: trimmedText;
|
: trimmedText;
|
||||||
|
|
||||||
// Use logical chapter numbering (index + 1) to match original generation titles
|
// Use logical chapter numbering (index + 1) to match original generation titles
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ import { useAudioContext } from '@/hooks/audio/useAudioContext';
|
||||||
import { getLastDocumentLocation, setLastDocumentLocation } from '@/lib/dexie';
|
import { getLastDocumentLocation, setLastDocumentLocation } from '@/lib/dexie';
|
||||||
import { useBackgroundState } from '@/hooks/audio/useBackgroundState';
|
import { useBackgroundState } from '@/hooks/audio/useBackgroundState';
|
||||||
import { withRetry, generateTTS, alignAudio } from '@/lib/client';
|
import { withRetry, generateTTS, alignAudio } from '@/lib/client';
|
||||||
import { preprocessSentenceForAudio, processTextToSentences } from '@/lib/nlp';
|
import { preprocessSentenceForAudio, splitTextToTtsBlocks } from '@/lib/nlp';
|
||||||
import { isKokoroModel } from '@/utils/voice';
|
import { isKokoroModel } from '@/utils/voice';
|
||||||
import type {
|
import type {
|
||||||
TTSLocation,
|
TTSLocation,
|
||||||
|
|
@ -379,13 +379,13 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
* @param {string} text - The text to be processed
|
* @param {string} text - The text to be processed
|
||||||
* @returns {Promise<string[]>} Array of processed sentences
|
* @returns {Promise<string[]>} Array of processed sentences
|
||||||
*/
|
*/
|
||||||
const processTextToSentencesLocal = useCallback(async (text: string): Promise<string[]> => {
|
const splitTextToTtsBlocksLocal = useCallback(async (text: string): Promise<string[]> => {
|
||||||
if (text.length < 1) {
|
if (text.length < 1) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the shared utility directly instead of making an API call
|
// Use the shared utility directly instead of making an API call
|
||||||
return processTextToSentences(text);
|
return splitTextToTtsBlocks(text);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -579,7 +579,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
abortAudio(true); // Clear pending requests since text is changing
|
abortAudio(true); // Clear pending requests since text is changing
|
||||||
setIsProcessing(true); // Set processing state before text processing starts
|
setIsProcessing(true); // Set processing state before text processing starts
|
||||||
|
|
||||||
processTextToSentencesLocal(workingText)
|
splitTextToTtsBlocksLocal(workingText)
|
||||||
.then(newSentences => {
|
.then(newSentences => {
|
||||||
if (newSentences.length === 0) {
|
if (newSentences.length === 0) {
|
||||||
console.warn('No sentences found in text');
|
console.warn('No sentences found in text');
|
||||||
|
|
@ -658,7 +658,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [isPlaying, handleBlankSection, abortAudio, processTextToSentencesLocal, pendingRestoreIndex, isEPUB, smartSentenceSplitting]);
|
}, [isPlaying, handleBlankSection, abortAudio, splitTextToTtsBlocksLocal, pendingRestoreIndex, isEPUB, smartSentenceSplitting]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggles the playback state between playing and paused
|
* Toggles the playback state between playing and paused
|
||||||
|
|
|
||||||
161
src/lib/nlp.ts
161
src/lib/nlp.ts
|
|
@ -9,6 +9,113 @@ import nlp from 'compromise';
|
||||||
|
|
||||||
const MAX_BLOCK_LENGTH = 450;
|
const MAX_BLOCK_LENGTH = 450;
|
||||||
|
|
||||||
|
const splitOversizedText = (text: string, maxLen: number): string[] => {
|
||||||
|
const normalized = text.replace(/\s+/g, ' ').trim();
|
||||||
|
if (!normalized) return [];
|
||||||
|
if (normalized.length <= maxLen) return [normalized];
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
const MAX_OVERFLOW = maxLen; // allow finishing the sentence up to +maxLen chars
|
||||||
|
const CLOSERS = new Set(['"', "'", '”', '’', ')', ']', '}']);
|
||||||
|
const BREAK_CHARS = new Set(['.', '!', '?']);
|
||||||
|
const SOFT_BREAK_CHARS = new Set([';', ':']);
|
||||||
|
|
||||||
|
const findPunctuationCut = (s: string, limit: number): number | null => {
|
||||||
|
for (let i = limit; i >= 0; i--) {
|
||||||
|
const ch = s[i];
|
||||||
|
if (!BREAK_CHARS.has(ch)) continue;
|
||||||
|
|
||||||
|
const prev = i > 0 ? s[i - 1] : '';
|
||||||
|
const next = i + 1 < s.length ? s[i + 1] : '';
|
||||||
|
|
||||||
|
// Avoid splitting inside decimals like 3.14
|
||||||
|
if (ch === '.' && /\d/.test(prev) && /\d/.test(next)) continue;
|
||||||
|
|
||||||
|
let end = i + 1;
|
||||||
|
while (end < s.length && CLOSERS.has(s[end])) end++;
|
||||||
|
const after = end < s.length ? s[end] : '';
|
||||||
|
|
||||||
|
// Allow a boundary at end/whitespace, or common PDF artifact where
|
||||||
|
// the next sentence starts immediately with an uppercase letter.
|
||||||
|
if (!after || /\s/.test(after) || /[A-Z]/.test(after)) return end;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findForwardPunctuationCut = (
|
||||||
|
s: string,
|
||||||
|
startIndex: number,
|
||||||
|
endIndex: number,
|
||||||
|
chars: Set<string>
|
||||||
|
): number | null => {
|
||||||
|
const start = Math.max(0, startIndex);
|
||||||
|
const end = Math.min(endIndex, s.length - 1);
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
const ch = s[i];
|
||||||
|
if (!chars.has(ch)) continue;
|
||||||
|
|
||||||
|
const prev = i > 0 ? s[i - 1] : '';
|
||||||
|
const next = i + 1 < s.length ? s[i + 1] : '';
|
||||||
|
|
||||||
|
if (ch === '.' && /\d/.test(prev) && /\d/.test(next)) continue;
|
||||||
|
|
||||||
|
let cut = i + 1;
|
||||||
|
while (cut < s.length && CLOSERS.has(s[cut])) cut++;
|
||||||
|
const after = cut < s.length ? s[cut] : '';
|
||||||
|
|
||||||
|
if (!after || /\s/.test(after) || /[A-Z]/.test(after)) return cut;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findSoftPunctuationCut = (s: string, limit: number): number | null => {
|
||||||
|
for (let i = limit; i >= 0; i--) {
|
||||||
|
const ch = s[i];
|
||||||
|
if (!SOFT_BREAK_CHARS.has(ch)) continue;
|
||||||
|
|
||||||
|
let end = i + 1;
|
||||||
|
while (end < s.length && CLOSERS.has(s[end])) end++;
|
||||||
|
const after = end < s.length ? s[end] : '';
|
||||||
|
if (!after || /\s/.test(after) || /[A-Z]/.test(after)) return end;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let remaining = normalized;
|
||||||
|
while (remaining.length > maxLen) {
|
||||||
|
const backwardLimit = Math.min(maxLen, remaining.length - 1);
|
||||||
|
const forwardLimit = Math.min(maxLen + MAX_OVERFLOW, remaining.length - 1);
|
||||||
|
|
||||||
|
let cut =
|
||||||
|
findPunctuationCut(remaining, backwardLimit) ??
|
||||||
|
findForwardPunctuationCut(remaining, maxLen, forwardLimit, BREAK_CHARS) ??
|
||||||
|
findSoftPunctuationCut(remaining, backwardLimit) ??
|
||||||
|
findForwardPunctuationCut(remaining, maxLen, forwardLimit, SOFT_BREAK_CHARS) ??
|
||||||
|
remaining.lastIndexOf(' ', maxLen);
|
||||||
|
|
||||||
|
if (cut === 0 || cut === -1) {
|
||||||
|
// No whitespace or punctuation; hard-cut for extremely long tokens.
|
||||||
|
cut = maxLen;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunk = remaining.slice(0, cut).trim();
|
||||||
|
if (chunk) parts.push(chunk);
|
||||||
|
remaining = remaining.slice(cut).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remaining) parts.push(remaining);
|
||||||
|
return parts;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeSentenceBoundariesForNlp = (text: string): string => {
|
||||||
|
// PDF extraction sometimes yields "...end.Next..." with no whitespace.
|
||||||
|
// Insert a space only when it looks like a sentence boundary (lower/digit before,
|
||||||
|
// uppercase after) to avoid breaking abbreviations like "U.S.A".
|
||||||
|
return text
|
||||||
|
.replace(/([a-z0-9])([.!?])(?=[A-Z])/g, '$1$2 ')
|
||||||
|
.replace(/([a-z0-9][.!?][\"”’)\]])(?=[A-Z])/g, '$1 ');
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preprocesses text for audio generation by cleaning up various text artifacts
|
* Preprocesses text for audio generation by cleaning up various text artifacts
|
||||||
*
|
*
|
||||||
|
|
@ -31,14 +138,18 @@ export const preprocessSentenceForAudio = (text: string): string => {
|
||||||
* @param {string} text - The text to split into sentences
|
* @param {string} text - The text to split into sentences
|
||||||
* @returns {string[]} Array of sentence blocks
|
* @returns {string[]} Array of sentence blocks
|
||||||
*/
|
*/
|
||||||
export const splitIntoSentences = (text: string): string[] => {
|
export const splitTextToTtsBlocks = (text: string): string[] => {
|
||||||
const paragraphs = text.split(/\n+/);
|
// Treat double-newlines as paragraph boundaries; single newlines are usually
|
||||||
|
// just PDF line wrapping and should not force sentence/block boundaries.
|
||||||
|
const paragraphs = text.split(/\n{2,}/);
|
||||||
const blocks: string[] = [];
|
const blocks: string[] = [];
|
||||||
|
|
||||||
for (const paragraph of paragraphs) {
|
for (const paragraph of paragraphs) {
|
||||||
if (!paragraph.trim()) continue;
|
if (!paragraph.trim()) continue;
|
||||||
|
|
||||||
const cleanedText = preprocessSentenceForAudio(paragraph);
|
const cleanedText = normalizeSentenceBoundariesForNlp(
|
||||||
|
preprocessSentenceForAudio(paragraph)
|
||||||
|
);
|
||||||
const doc = nlp(cleanedText);
|
const doc = nlp(cleanedText);
|
||||||
const rawSentences = doc.sentences().out('array') as string[];
|
const rawSentences = doc.sentences().out('array') as string[];
|
||||||
|
|
||||||
|
|
@ -49,14 +160,17 @@ export const splitIntoSentences = (text: string): string[] => {
|
||||||
|
|
||||||
for (const sentence of mergedSentences) {
|
for (const sentence of mergedSentences) {
|
||||||
const trimmedSentence = sentence.trim();
|
const trimmedSentence = sentence.trim();
|
||||||
|
const sentenceParts = splitOversizedText(trimmedSentence, MAX_BLOCK_LENGTH);
|
||||||
|
|
||||||
if (currentBlock && (currentBlock.length + trimmedSentence.length + 1) > MAX_BLOCK_LENGTH) {
|
for (const sentencePart of sentenceParts) {
|
||||||
blocks.push(currentBlock.trim());
|
if (currentBlock && (currentBlock.length + sentencePart.length + 1) > MAX_BLOCK_LENGTH) {
|
||||||
currentBlock = trimmedSentence;
|
blocks.push(currentBlock.trim());
|
||||||
} else {
|
currentBlock = sentencePart;
|
||||||
currentBlock = currentBlock
|
} else {
|
||||||
? `${currentBlock} ${trimmedSentence}`
|
currentBlock = currentBlock
|
||||||
: trimmedSentence;
|
? `${currentBlock} ${sentencePart}`
|
||||||
|
: sentencePart;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,29 +183,23 @@ export const splitIntoSentences = (text: string): string[] => {
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main sentence processing function that handles both short and long texts
|
* Normalizes text for single-shot TTS generation (e.g., a whole PDF page).
|
||||||
|
* Uses the same logic as `splitTextToTtsBlocks`, but returns a single string.
|
||||||
*
|
*
|
||||||
* @param {string} text - The text to process
|
* @param {string} text - The text to process
|
||||||
* @returns {string[]} Array of processed sentences/blocks
|
* @returns {string} Normalized text
|
||||||
*/
|
*/
|
||||||
export const processTextToSentences = (text: string): string[] => {
|
export const normalizeTextForTts = (text: string): string =>
|
||||||
if (!text || text.length < 1) {
|
splitTextToTtsBlocks(text).join(' ');
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always use the full splitting logic so we consistently respect
|
|
||||||
// sentence boundaries and quoted dialogue, even for shorter texts.
|
|
||||||
return splitIntoSentences(text);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets raw sentences from text without preprocessing or grouping
|
* Extracts raw sentence strings from text without preprocessing or block grouping.
|
||||||
* This is useful for text matching and highlighting
|
* Useful for text matching and highlighting.
|
||||||
*
|
*
|
||||||
* @param {string} text - The text to extract sentences from
|
* @param {string} text - The text to extract sentences from
|
||||||
* @returns {string[]} Array of raw sentences
|
* @returns {string[]} Array of raw sentences
|
||||||
*/
|
*/
|
||||||
export const getRawSentences = (text: string): string[] => {
|
export const extractRawSentences = (text: string): string[] => {
|
||||||
if (!text || text.length < 1) {
|
if (!text || text.length < 1) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
@ -111,8 +219,8 @@ export const processTextWithMapping = (text: string): {
|
||||||
rawSentences: string[];
|
rawSentences: string[];
|
||||||
sentenceMapping: Array<{ processedIndex: number; rawIndices: number[] }>;
|
sentenceMapping: Array<{ processedIndex: number; rawIndices: number[] }>;
|
||||||
} => {
|
} => {
|
||||||
const rawSentences = getRawSentences(text);
|
const rawSentences = extractRawSentences(text);
|
||||||
const processedSentences = processTextToSentences(text);
|
const processedSentences = splitTextToTtsBlocks(text);
|
||||||
|
|
||||||
// Create a mapping between processed sentences and raw sentences
|
// Create a mapping between processed sentences and raw sentences
|
||||||
const sentenceMapping: Array<{ processedIndex: number; rawIndices: number[] }> = [];
|
const sentenceMapping: Array<{ processedIndex: number; rawIndices: number[] }> = [];
|
||||||
|
|
@ -149,6 +257,7 @@ export const processTextWithMapping = (text: string): {
|
||||||
sentenceMapping
|
sentenceMapping
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper functions to merge quoted dialogue across sentences
|
// Helper functions to merge quoted dialogue across sentences
|
||||||
const countDoubleQuotes = (s: string): number => {
|
const countDoubleQuotes = (s: string): number => {
|
||||||
const matches = s.match(/["“”]/g);
|
const matches = s.match(/["“”]/g);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import {
|
import {
|
||||||
preprocessSentenceForAudio,
|
preprocessSentenceForAudio,
|
||||||
splitIntoSentences,
|
splitTextToTtsBlocks,
|
||||||
processTextWithMapping
|
processTextWithMapping
|
||||||
} from '../../src/lib/nlp';
|
} from '../../src/lib/nlp';
|
||||||
|
|
||||||
|
|
@ -31,24 +31,24 @@ test.describe('preprocessSentenceForAudio', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('splitIntoSentences', () => {
|
test.describe('splitTextToTtsBlocks', () => {
|
||||||
test('groups short sentences into single block', () => {
|
test('groups short sentences into single block', () => {
|
||||||
const input = 'First sentence. Second sentence.';
|
const input = 'First sentence. Second sentence.';
|
||||||
const result = splitIntoSentences(input);
|
const result = splitTextToTtsBlocks(input);
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0]).toBe('First sentence. Second sentence.');
|
expect(result[0]).toBe('First sentence. Second sentence.');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('merges quoted dialogue (double quotes)', () => {
|
test('merges quoted dialogue (double quotes)', () => {
|
||||||
const input = 'He said, "This should be one block." and walked away.';
|
const input = 'He said, "This should be one block." and walked away.';
|
||||||
const result = splitIntoSentences(input);
|
const result = splitTextToTtsBlocks(input);
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0]).toBe('He said, "This should be one block." and walked away.');
|
expect(result[0]).toBe('He said, "This should be one block." and walked away.');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('merges quoted dialogue (curly quotes)', () => {
|
test('merges quoted dialogue (curly quotes)', () => {
|
||||||
const input = 'She replied, “This also should be merged.” then smiled.';
|
const input = 'She replied, “This also should be merged.” then smiled.';
|
||||||
const result = splitIntoSentences(input);
|
const result = splitTextToTtsBlocks(input);
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0]).toBe('She replied, “This also should be merged.” then smiled.');
|
expect(result[0]).toBe('She replied, “This also should be merged.” then smiled.');
|
||||||
});
|
});
|
||||||
|
|
@ -64,12 +64,75 @@ test.describe('splitIntoSentences', () => {
|
||||||
// 5 sentences = 505 chars + 4 spaces = 509 chars (> 450). Should split.
|
// 5 sentences = 505 chars + 4 spaces = 509 chars (> 450). Should split.
|
||||||
|
|
||||||
const input = Array(5).fill(sentence).join(' ');
|
const input = Array(5).fill(sentence).join(' ');
|
||||||
const result = splitIntoSentences(input);
|
const result = splitTextToTtsBlocks(input);
|
||||||
|
|
||||||
expect(result.length).toBeGreaterThan(1);
|
expect(result.length).toBeGreaterThan(1);
|
||||||
// The first block should contain as many as possible
|
// The first block should contain as many as possible
|
||||||
expect(result[0].length).toBeLessThanOrEqual(450);
|
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.endsWith('.')).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
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.
|
||||||
|
expect(result.length).toBeGreaterThan(1);
|
||||||
|
expect(result[0].endsWith('.')).toBe(true);
|
||||||
|
expect(result[0].includes('end.')).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('processTextWithMapping', () => {
|
test.describe('processTextWithMapping', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue