From b87b83310bd73e2e5cab4fca0098a0fa8d9cb3b8 Mon Sep 17 00:00:00 2001 From: Richard Roberson Date: Tue, 25 Feb 2025 00:58:32 -0700 Subject: [PATCH] Rely on TTS API for splitting --- README.md | 2 +- src/app/api/tts/route.ts | 8 +- src/components/DocumentSettings.tsx | 230 ++++++++++++++-------------- src/contexts/EPUBContext.tsx | 142 +++++------------ .env.template => template.env | 0 5 files changed, 157 insertions(+), 225 deletions(-) rename .env.template => template.env (100%) diff --git a/README.md b/README.md index 64e2885..74a8fdb 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ services: 3. Configure the environment: ```bash - cp .env.template .env + cp template.env .env # Edit .env with your configuration settings ``` > Note: The base URL for the TTS API should be accessible and relative to the Next.js server diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts index 9666b98..604a5ab 100644 --- a/src/app/api/tts/route.ts +++ b/src/app/api/tts/route.ts @@ -20,7 +20,7 @@ export async function POST(req: NextRequest) { // Initialize OpenAI client with abort signal const openai = new OpenAI({ apiKey: openApiKey, - baseURL: openApiBaseUrl || 'https://api.openai.com/v1', + baseURL: openApiBaseUrl, }); // Request audio from OpenAI and pass along the abort signal @@ -33,15 +33,15 @@ export async function POST(req: NextRequest) { // Get the audio data as array buffer // This will also be aborted if the client cancels - const arrayBuffer = await response.arrayBuffer(); + const stream = response.body; // Return audio data with appropriate headers - return new NextResponse(arrayBuffer); + return new NextResponse(stream); } catch (error) { // Check if this was an abort error if (error instanceof Error && error.name === 'AbortError') { console.log('TTS request aborted by client'); - return new Response(null, { status: 499 }); // Use 499 status for client closed request + return new NextResponse(null, { status: 499 }); // Use 499 status for client closed request } console.error('Error generating TTS:', error); diff --git a/src/components/DocumentSettings.tsx b/src/components/DocumentSettings.tsx index 40bde50..c251bbf 100644 --- a/src/components/DocumentSettings.tsx +++ b/src/components/DocumentSettings.tsx @@ -1,11 +1,13 @@ 'use client'; import { Fragment, useState, useRef } from 'react'; -import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Listbox, ListboxButton, ListboxOptions, ListboxOption, Button } from '@headlessui/react'; +import { Dialog, DialogPanel, Transition, TransitionChild, Listbox, ListboxButton, ListboxOptions, ListboxOption, Button } from '@headlessui/react'; import { useConfig, ViewType } from '@/contexts/ConfigContext'; import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons'; import { useEPUB } from '@/contexts/EPUBContext'; +const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; + interface DocViewSettingsProps { isOpen: boolean; setIsOpen: (isOpen: boolean) => void; @@ -93,136 +95,126 @@ export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsPro leaveTo="opacity-0 scale-95" > - - View Settings - -
-
- {!epub &&
- - updateConfigKey('viewType', newView.id as ViewType)} +
+ {isDev &&
+ {!isGenerating ? ( + + ) : ( +
+
+
- - {selectedView.id === 'scroll' && ( -

- Note: Continuous scroll may perform poorly for larger documents. -

- )} -
} +
+ {Math.round(progress)}% complete + +
+
+ )} +
} + {!epub &&
+ + updateConfigKey('viewType', newView.id as ViewType)} + > +
+ + {selectedView.name} + + + + + + + {viewTypes.map((view) => ( + + `relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-accent/10 text-accent' : 'text-foreground' + }` + } + value={view} + > + {({ selected }) => ( + <> + + {view.name} + + {selected ? ( + + + + ) : null} + + )} + + ))} + + +
+
+ {selectedView.id === 'scroll' && ( +

+ Note: Continuous scroll may perform poorly for larger documents. +

+ )} +
} +
+ +

+ Automatically skip pages with no text content +

+
+ {epub && (

- Automatically skip pages with no text content + Apply the current app theme to the EPUB viewer

- {epub && ( - <> -
- -

- Apply the current app theme to the EPUB viewer -

-
-
- {!isGenerating ? ( - - ) : ( -
-
-
-
-
- {Math.round(progress)}% complete - -
-
- )} -
- - )} -
+ )}
diff --git a/src/contexts/EPUBContext.tsx b/src/contexts/EPUBContext.tsx index 513d889..4e3d7ae 100644 --- a/src/contexts/EPUBContext.tsx +++ b/src/contexts/EPUBContext.tsx @@ -191,123 +191,63 @@ export function EPUBProvider({ children }: { children: ReactNode }) { // Create an array to store all audio chunks const audioChunks: ArrayBuffer[] = []; - let processedSentences = 0; - let totalSentences = 0; - - // First, count total sentences - for (const text of textArray) { - if (!text.trim()) continue; - - const nlpResponse = await fetch('/api/nlp', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text }), - }); - - if (!nlpResponse.ok) { - throw new Error(`NLP processing failed with status ${nlpResponse.status}`); - } - - const nlpData = await nlpResponse.json(); - const sentences = nlpData?.sentences; - if (sentences && Array.isArray(sentences)) { - totalSentences += sentences.length; - } - } + let processedSections = 0; + const totalSections = textArray.length; // Process each section of text for (const text of textArray) { - if (!text.trim()) continue; - // Check for cancellation if (signal?.aborted) { - // If cancelled, return what we have so far const partialBuffer = combineAudioChunks(audioChunks); return partialBuffer; } - const nlpResponse = await fetch('/api/nlp', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text }), - }); - - if (!nlpResponse.ok) { - throw new Error(`NLP processing failed with status ${nlpResponse.status}`); - } - - const nlpData = await nlpResponse.json(); - const sentences = nlpData?.sentences; - - if (!sentences || !Array.isArray(sentences) || sentences.length === 0) { - console.warn('No valid sentences returned from NLP, processing text block as single sentence'); + if (!text.trim()) { + processedSections++; continue; } - // Process each sentence through TTS with retries - for (const sentence of sentences) { - // Check for cancellation - if (signal?.aborted) { + try { + const ttsResponse = await fetch('/api/tts', { + method: 'POST', + headers: { + 'x-openai-key': apiKey, + 'x-openai-base-url': baseUrl, + }, + body: JSON.stringify({ + text: text.trim(), + voice: voice, + speed: voiceSpeed, + }), + signal // Pass the AbortSignal to the fetch request + }); + + if (!ttsResponse.ok) { + throw new Error(`TTS processing failed with status ${ttsResponse.status}`); + } + + const audioBuffer = await ttsResponse.arrayBuffer(); + if (audioBuffer.byteLength === 0) { + throw new Error('Received empty audio buffer from TTS'); + } + + audioChunks.push(audioBuffer); + + // Add a small pause between sections (500ms of silence) + const silenceBuffer = new ArrayBuffer(24000); + audioChunks.push(silenceBuffer); + + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + console.log('TTS request aborted'); const partialBuffer = combineAudioChunks(audioChunks); return partialBuffer; } - - if (!sentence || typeof sentence !== 'string' || !sentence.trim()) { - processedSentences++; - continue; - } - - let retryCount = 0; - const maxRetries = 2; - let success = false; - - while (retryCount <= maxRetries && !success) { - try { - const ttsResponse = await fetch('/api/tts', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-openai-key': apiKey || '', - 'x-openai-base-url': baseUrl || 'https://api.openai.com/v1', - }, - body: JSON.stringify({ - text: sentence.trim(), - voice: voice, - speed: voiceSpeed, - }), - }); - - if (!ttsResponse.ok) { - throw new Error(`TTS processing failed with status ${ttsResponse.status}`); - } - - const audioBuffer = await ttsResponse.arrayBuffer(); - if (audioBuffer.byteLength === 0) { - throw new Error('Received empty audio buffer from TTS'); - } - - audioChunks.push(audioBuffer); - success = true; - } catch (error) { - retryCount++; - if (retryCount <= maxRetries) { - console.warn(`TTS generation failed, attempt ${retryCount} of ${maxRetries}:`, error); - await new Promise(resolve => setTimeout(resolve, 1000)); - } else { - console.error(`Failed to generate audio after ${maxRetries} retries, skipping sentence:`, sentence); - } - } - } - - if (success) { - // Add a small pause between sentences (500ms of silence) - const silenceBuffer = new ArrayBuffer(24000); - audioChunks.push(silenceBuffer); - } - - processedSentences++; - onProgress((processedSentences / totalSentences) * 100); + console.error('Error processing section:', error); } + + processedSections++; + onProgress((processedSections / totalSections) * 100); } if (audioChunks.length === 0) { diff --git a/.env.template b/template.env similarity index 100% rename from .env.template rename to template.env