From f594ca836bc719f9acf6c94b04b95afce7e5a1fa Mon Sep 17 00:00:00 2001 From: Victor Sonck Date: Fri, 20 Jun 2025 08:25:29 +0200 Subject: [PATCH 1/2] Refactored NLP into a single, shared function for splitting to sentences. now both handleClick and global sentences are the exact same, so the sentenceIndex will always be correct. --- src/app/api/nlp/route.ts | 58 +------------- src/contexts/TTSContext.tsx | 23 ++---- src/utils/nlp.ts | 153 ++++++++++++++++++++++++++++++++++++ src/utils/pdf.ts | 6 +- 4 files changed, 167 insertions(+), 73 deletions(-) create mode 100644 src/utils/nlp.ts diff --git a/src/app/api/nlp/route.ts b/src/app/api/nlp/route.ts index 2bea22f..5f49e55 100644 --- a/src/app/api/nlp/route.ts +++ b/src/app/api/nlp/route.ts @@ -1,51 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import nlp from 'compromise'; - -const MAX_BLOCK_LENGTH = 300; - -const preprocessSentenceForAudio = (text: string): string => { - return text - .replace(/\S*(?:https?:\/\/|www\.)([^\/\s]+)(?:\/\S*)?/gi, '- (link to $1) -') - .replace(/(\w+)-\s+(\w+)/g, '$1$2') // Remove hyphenation - // Remove special character * - .replace(/\*/g, '') - .replace(/\s+/g, ' ') - .trim(); -}; - -const splitIntoSentences = (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[]; - - let currentBlock = ''; - - for (const sentence of rawSentences) { - 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; -}; +import { processTextToSentences } from '@/utils/nlp'; export async function POST(req: NextRequest) { // First check if the request body is empty @@ -104,14 +58,8 @@ export async function POST(req: NextRequest) { ); } - if (text.length <= MAX_BLOCK_LENGTH) { - // Single sentence preprocessing - const cleanedText = preprocessSentenceForAudio(text); - return NextResponse.json({ sentences: [cleanedText] }); - } - - // Full text splitting into sentences - const sentences = splitIntoSentences(text); + // Use the shared utility function for consistent processing + const sentences = processTextToSentences(text); return NextResponse.json({ sentences }); } catch (error) { console.error('Error processing text:', error); diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index 90e4a69..8d8a6c3 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -37,6 +37,7 @@ import { useAudioContext } from '@/hooks/audio/useAudioContext'; import { getLastDocumentLocation } from '@/utils/indexedDB'; import { useBackgroundState } from '@/hooks/audio/useBackgroundState'; import { withRetry } from '@/utils/audio'; +import { processTextToSentences } from '@/utils/nlp'; // Media globals declare global { @@ -150,28 +151,18 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const activeAbortControllers = useRef>(new Set()); /** - * Processes text through the NLP API to split it into sentences + * Processes text into sentences using the shared NLP utility * * @param {string} text - The text to be processed * @returns {Promise} Array of processed sentences */ - const processTextToSentences = useCallback(async (text: string): Promise => { + const processTextToSentencesLocal = useCallback(async (text: string): Promise => { if (text.length < 1) { return []; } - const response = await fetch('/api/nlp', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text }), - }); - - if (!response.ok) { - throw new Error('Failed to process text'); - } - - const { sentences } = await response.json(); - return sentences; + // Use the shared utility directly instead of making an API call + return processTextToSentences(text); }, []); /** @@ -308,7 +299,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement setIsProcessing(true); // Set processing state before text processing starts console.log('Setting text:', text); - processTextToSentences(text) + processTextToSentencesLocal(text) .then(newSentences => { if (newSentences.length === 0) { console.warn('No sentences found in text'); @@ -337,7 +328,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement duration: 3000, }); }); - }, [isPlaying, handleBlankSection, abortAudio, processTextToSentences]); + }, [isPlaying, handleBlankSection, abortAudio, processTextToSentencesLocal]); /** * Toggles the playback state between playing and paused diff --git a/src/utils/nlp.ts b/src/utils/nlp.ts new file mode 100644 index 0000000..f60d314 --- /dev/null +++ b/src/utils/nlp.ts @@ -0,0 +1,153 @@ +/** + * Natural Language Processing Utilities + * + * This module provides consistent sentence processing functionality across the application. + * It handles text preprocessing, sentence splitting, and block creation for optimal TTS processing. + */ + +import nlp from 'compromise'; + +const MAX_BLOCK_LENGTH = 300; + +/** + * Preprocesses text for audio generation by cleaning up various text artifacts + * + * @param {string} text - The text to preprocess + * @returns {string} The cleaned text + */ +export const preprocessSentenceForAudio = (text: string): string => { + return text + .replace(/\S*(?:https?:\/\/|www\.)([^\/\s]+)(?:\/\S*)?/gi, '- (link to $1) -') + .replace(/(\w+)-\s+(\w+)/g, '$1$2') // Remove hyphenation + // Remove special character * + .replace(/\*/g, '') + .replace(/\s+/g, ' ') + .trim(); +}; + +/** + * Splits text into sentences and groups them into blocks suitable for TTS processing + * + * @param {string} text - The text to split into sentences + * @returns {string[]} Array of sentence blocks + */ +export const splitIntoSentences = (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[]; + + let currentBlock = ''; + + for (const sentence of rawSentences) { + 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; +}; + +/** + * Main sentence processing function that handles both short and long texts + * + * @param {string} text - The text to process + * @returns {string[]} Array of processed sentences/blocks + */ +export const processTextToSentences = (text: string): string[] => { + if (!text || text.length < 1) { + return []; + } + + if (text.length <= MAX_BLOCK_LENGTH) { + // Single sentence preprocessing + const cleanedText = preprocessSentenceForAudio(text); + return [cleanedText]; + } + + // Full text splitting into sentences + return splitIntoSentences(text); +}; + +/** + * Gets raw sentences from text without preprocessing or grouping + * This is useful for text matching and highlighting + * + * @param {string} text - The text to extract sentences from + * @returns {string[]} Array of raw sentences + */ +export const getRawSentences = (text: string): string[] => { + if (!text || text.length < 1) { + return []; + } + + return nlp(text).sentences().out('array') as string[]; +}; + +/** + * Enhanced sentence processing that returns both processed sentences and raw sentences + * This allows for better mapping between the two for click-to-highlight functionality + * + * @param {string} text - The text to process + * @returns {Object} Object containing processed sentences and raw sentences with mapping + */ +export const processTextWithMapping = (text: string): { + processedSentences: string[]; + rawSentences: string[]; + sentenceMapping: Array<{ processedIndex: number; rawIndices: number[] }>; +} => { + const rawSentences = getRawSentences(text); + const processedSentences = processTextToSentences(text); + + // Create a mapping between processed sentences and raw sentences + const sentenceMapping: Array<{ processedIndex: number; rawIndices: number[] }> = []; + + // For simple mapping, we'll track which raw sentences contributed to each processed sentence + let rawIndex = 0; + + for (let processedIndex = 0; processedIndex < processedSentences.length; processedIndex++) { + const processedSentence = processedSentences[processedIndex]; + const rawIndices: number[] = []; + + // Find which raw sentences are contained in this processed sentence + let remainingText = processedSentence; + + while (rawIndex < rawSentences.length && remainingText.length > 0) { + const rawSentence = rawSentences[rawIndex]; + const cleanedRawSentence = preprocessSentenceForAudio(rawSentence); + + if (remainingText.includes(cleanedRawSentence) || cleanedRawSentence.includes(remainingText)) { + rawIndices.push(rawIndex); + rawIndex++; + break; + } else { + rawIndex++; + } + } + + sentenceMapping.push({ processedIndex, rawIndices }); + } + + return { + processedSentences, + rawSentences, + sentenceMapping + }; +}; \ No newline at end of file diff --git a/src/utils/pdf.ts b/src/utils/pdf.ts index 105172a..e0059e5 100644 --- a/src/utils/pdf.ts +++ b/src/utils/pdf.ts @@ -1,9 +1,9 @@ import { pdfjs } from 'react-pdf'; -import nlp from 'compromise'; import stringSimilarity from 'string-similarity'; import type { TextItem } from 'pdfjs-dist/types/src/display/api'; import type { PDFDocumentProxy } from 'pdfjs-dist'; import "core-js/proposals/promise-with-resolvers"; +import { processTextToSentences } from '@/utils/nlp'; // Function to detect if we need to use legacy build function shouldUseLegacyBuild() { @@ -325,7 +325,9 @@ export function handleTextClick( if (bestMatch.rating >= similarityThreshold) { const matchText = bestMatch.text; - const sentences = nlp(pdfText).sentences().out('array') as string[]; + // Use the same sentence processing logic as TTSContext for consistency + const sentences = processTextToSentences(pdfText); + console.log("sentences inside handleTextClick: %d", sentences.length) let bestSentenceMatch = { sentence: '', rating: 0 }; for (const sentence of sentences) { From 4c92b9875c73a26c677d79c1e3c114f9ed8225c2 Mon Sep 17 00:00:00 2001 From: Victor Sonck Date: Sun, 22 Jun 2025 15:48:33 +0200 Subject: [PATCH 2/2] Remove the NLP route, since it is no longer used. --- src/app/api/nlp/route.ts | 71 ---------------------------------------- 1 file changed, 71 deletions(-) delete mode 100644 src/app/api/nlp/route.ts diff --git a/src/app/api/nlp/route.ts b/src/app/api/nlp/route.ts deleted file mode 100644 index 5f49e55..0000000 --- a/src/app/api/nlp/route.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { processTextToSentences } from '@/utils/nlp'; - -export async function POST(req: NextRequest) { - // First check if the request body is empty - const contentLength = req.headers.get('content-length'); - if (!contentLength || parseInt(contentLength) === 0) { - return NextResponse.json( - { error: 'Request body is empty' }, - { status: 400 } - ); - } - - // Check content type - const contentType = req.headers.get('content-type'); - if (!contentType?.includes('application/json')) { - return NextResponse.json( - { error: 'Content-Type must be application/json' }, - { status: 400 } - ); - } - - try { - // Get the raw body text first to validate it's not empty - const rawBody = await req.text(); - if (!rawBody?.trim()) { - return NextResponse.json( - { error: 'Request body is empty' }, - { status: 400 } - ); - } - - // Try to parse the JSON - let body; - try { - body = JSON.parse(rawBody); - } catch (e) { - console.error('JSON parse error:', e); - return NextResponse.json( - { error: 'Invalid JSON format' }, - { status: 400 } - ); - } - - // Validate the parsed body has the required text field - if (!body || typeof body !== 'object') { - return NextResponse.json( - { error: 'Invalid request body format' }, - { status: 400 } - ); - } - - const { text } = body; - if (!text || typeof text !== 'string') { - return NextResponse.json( - { error: 'Missing or invalid text field' }, - { status: 400 } - ); - } - - // Use the shared utility function for consistent processing - const sentences = processTextToSentences(text); - return NextResponse.json({ sentences }); - } catch (error) { - console.error('Error processing text:', error); - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ); - } -}