Re-add AbortController API, fixes duplicate playback

This commit is contained in:
Richard Roberson 2025-02-23 01:29:10 -07:00
parent cd00ad2b9b
commit 0be34a09fe
2 changed files with 67 additions and 36 deletions

View file

@ -17,26 +17,33 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 }); return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
} }
// Initialize OpenAI client // Initialize OpenAI client with abort signal
const openai = new OpenAI({ const openai = new OpenAI({
apiKey: openApiKey, apiKey: openApiKey,
baseURL: openApiBaseUrl, baseURL: openApiBaseUrl,
}); });
// Request audio from OpenAI // Request audio from OpenAI and pass along the abort signal
const response = await openai.audio.speech.create({ const response = await openai.audio.speech.create({
model: 'tts-1', model: 'tts-1',
voice: voice as "alloy", voice: voice as "alloy",
input: text, input: text,
speed: speed, speed: speed,
}); }, { signal: req.signal }); // Pass the abort signal to OpenAI client
// Get the audio data as array buffer // Get the audio data as array buffer
// This will also be aborted if the client cancels
const arrayBuffer = await response.arrayBuffer(); const arrayBuffer = await response.arrayBuffer();
// Return audio data with appropriate headers // Return audio data with appropriate headers
return new NextResponse(arrayBuffer); return new NextResponse(arrayBuffer);
} catch (error) { } 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
}
console.error('Error generating TTS:', error); console.error('Error generating TTS:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Failed to generate audio' }, { error: 'Failed to generate audio' },

View file

@ -89,9 +89,9 @@ const TTSContext = createContext<TTSContextType | undefined>(undefined);
*/ */
export function TTSProvider({ children }: { children: ReactNode }) { export function TTSProvider({ children }: { children: ReactNode }) {
// Configuration context consumption // Configuration context consumption
const { const {
apiKey: openApiKey, apiKey: openApiKey,
baseUrl: openApiBaseUrl, baseUrl: openApiBaseUrl,
isLoading: configIsLoading, isLoading: configIsLoading,
voiceSpeed, voiceSpeed,
voice: configVoice, voice: configVoice,
@ -139,6 +139,8 @@ export function TTSProvider({ children }: { children: ReactNode }) {
// Track pending preload requests // Track pending preload requests
const preloadRequests = useRef<Map<string, Promise<string>>>(new Map()); const preloadRequests = useRef<Map<string, Promise<string>>>(new Map());
// Track active abort controllers for TTS requests
const activeAbortControllers = useRef<Set<AbortController>>(new Set());
//console.log('page:', currDocPage, 'pages:', currDocPages); //console.log('page:', currDocPage, 'pages:', currDocPages);
@ -177,7 +179,15 @@ export function TTSProvider({ children }: { children: ReactNode }) {
activeHowl.unload(); // Ensure Howl instance is fully cleaned up activeHowl.unload(); // Ensure Howl instance is fully cleaned up
setActiveHowl(null); setActiveHowl(null);
} }
if (clearPending) { if (clearPending) {
// Abort all active TTS requests
console.log('Aborting active TTS requests');
activeAbortControllers.current.forEach(controller => {
controller.abort();
});
activeAbortControllers.current.clear();
// Clear any pending preload requests
preloadRequests.current.clear(); preloadRequests.current.clear();
} }
}, [activeHowl]); }, [activeHowl]);
@ -189,13 +199,13 @@ export function TTSProvider({ children }: { children: ReactNode }) {
* @param {string | number} location - The target location to navigate to * @param {string | number} location - The target location to navigate to
* @param {boolean} keepPlaying - Whether to maintain playback state * @param {boolean} keepPlaying - Whether to maintain playback state
*/ */
const skipToLocation = useCallback((location: string | number) => { const skipToLocation = useCallback((location: string | number) => {
// Reset state for new content in correct order // Reset state for new content in correct order
abortAudio(); abortAudio();
setCurrentIndex(0); setCurrentIndex(0);
setSentences([]); setSentences([]);
setCurrDocPage(location); setCurrDocPage(location);
}, [abortAudio]); }, [abortAudio]);
/** /**
@ -205,29 +215,29 @@ export function TTSProvider({ children }: { children: ReactNode }) {
*/ */
const advance = useCallback(async (backwards = false) => { const advance = useCallback(async (backwards = false) => {
const nextIndex = currentIndex + (backwards ? -1 : 1); const nextIndex = currentIndex + (backwards ? -1 : 1);
// Handle within current page bounds // Handle within current page bounds
if (nextIndex < sentences.length && nextIndex >= 0) { if (nextIndex < sentences.length && nextIndex >= 0) {
setCurrentIndex(nextIndex); setCurrentIndex(nextIndex);
return; return;
} }
// For EPUB documents, always try to advance to next/prev section // For EPUB documents, always try to advance to next/prev section
if (isEPUB && locationChangeHandlerRef.current) { if (isEPUB && locationChangeHandlerRef.current) {
locationChangeHandlerRef.current(nextIndex >= sentences.length ? 'next' : 'prev'); locationChangeHandlerRef.current(nextIndex >= sentences.length ? 'next' : 'prev');
return; return;
} }
// For PDFs and other documents, check page bounds // For PDFs and other documents, check page bounds
if (!isEPUB) { if (!isEPUB) {
// Handle next/previous page transitions // Handle next/previous page transitions
if ((nextIndex >= sentences.length && currDocPageNumber < currDocPages!) || if ((nextIndex >= sentences.length && currDocPageNumber < currDocPages!) ||
(nextIndex < 0 && currDocPageNumber > 1)) { (nextIndex < 0 && currDocPageNumber > 1)) {
// Pass wasPlaying to maintain playback state during page turn // Pass wasPlaying to maintain playback state during page turn
skipToLocation(currDocPageNumber + (nextIndex >= sentences.length ? 1 : -1)); skipToLocation(currDocPageNumber + (nextIndex >= sentences.length ? 1 : -1));
return; return;
} }
// Handle end of document (PDF only) // Handle end of document (PDF only)
if (nextIndex >= sentences.length && currDocPageNumber >= currDocPages!) { if (nextIndex >= sentences.length && currDocPageNumber >= currDocPages!) {
setIsPlaying(false); setIsPlaying(false);
@ -248,7 +258,7 @@ export function TTSProvider({ children }: { children: ReactNode }) {
// Use advance to handle navigation for both EPUB and PDF // Use advance to handle navigation for both EPUB and PDF
advance(); advance();
toast.success(isEPUB ? 'Skipping blank section' : `Skipping blank page ${currDocPageNumber}`, { toast.success(isEPUB ? 'Skipping blank section' : `Skipping blank page ${currDocPageNumber}`, {
id: isEPUB ? `epub-section-skip` : `page-${currDocPageNumber}`, id: isEPUB ? `epub-section-skip` : `page-${currDocPageNumber}`,
iconTheme: { iconTheme: {
@ -274,13 +284,13 @@ export function TTSProvider({ children }: { children: ReactNode }) {
const setText = useCallback((text: string, shouldPause = false) => { const setText = useCallback((text: string, shouldPause = false) => {
// Check for blank section first // Check for blank section first
if (handleBlankSection(text)) return; if (handleBlankSection(text)) return;
// Keep track of previous state and pause playback // Keep track of previous state and pause playback
const wasPlaying = isPlaying; const wasPlaying = isPlaying;
setIsPlaying(false); setIsPlaying(false);
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
console.log('Setting text:', text); console.log('Setting text:', text);
processTextToSentences(text) processTextToSentences(text)
.then(newSentences => { .then(newSentences => {
@ -396,6 +406,10 @@ export function TTSProvider({ children }: { children: ReactNode }) {
try { try {
console.log('Requesting audio for sentence:', sentence); console.log('Requesting audio for sentence:', sentence);
// Create an AbortController for this request
const controller = new AbortController();
activeAbortControllers.current.add(controller);
const response = await fetch('/api/tts', { const response = await fetch('/api/tts', {
method: 'POST', method: 'POST',
headers: { headers: {
@ -408,8 +422,12 @@ export function TTSProvider({ children }: { children: ReactNode }) {
voice: voice, voice: voice,
speed: speed, speed: speed,
}), }),
signal: controller.signal,
}); });
// Remove the controller once the request is complete
activeAbortControllers.current.delete(controller);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to generate audio'); throw new Error('Failed to generate audio');
} }
@ -422,6 +440,12 @@ export function TTSProvider({ children }: { children: ReactNode }) {
return arrayBuffer; return arrayBuffer;
} catch (error) { } catch (error) {
// Check if this was an abort error
if (error instanceof Error && error.name === 'AbortError') {
console.log('TTS request aborted:', sentence.substring(0, 20));
return;
}
setIsPlaying(false); setIsPlaying(false);
toast.error('Failed to generate audio. Server not responding.', { toast.error('Failed to generate audio. Server not responding.', {
id: 'tts-api-error', id: 'tts-api-error',
@ -444,7 +468,7 @@ export function TTSProvider({ children }: { children: ReactNode }) {
*/ */
const processSentence = useCallback(async (sentence: string, preload = false): Promise<string> => { const processSentence = useCallback(async (sentence: string, preload = false): Promise<string> => {
if (!audioContext) throw new Error('Audio context not initialized'); if (!audioContext) throw new Error('Audio context not initialized');
// Check if there's a pending preload request for this sentence // Check if there's a pending preload request for this sentence
const pendingRequest = preloadRequests.current.get(sentence); const pendingRequest = preloadRequests.current.get(sentence);
if (pendingRequest) { if (pendingRequest) {
@ -459,7 +483,7 @@ export function TTSProvider({ children }: { children: ReactNode }) {
// Only set processing state if not preloading // Only set processing state if not preloading
if (!preload) setIsProcessing(true); if (!preload) setIsProcessing(true);
// Create the audio processing promise // Create the audio processing promise
const processPromise = (async () => { const processPromise = (async () => {
try { try {
@ -500,7 +524,7 @@ export function TTSProvider({ children }: { children: ReactNode }) {
if (!audioUrl) { if (!audioUrl) {
throw new Error('No audio URL generated'); throw new Error('No audio URL generated');
} }
// Force unload any previous Howl instance to free up resources // Force unload any previous Howl instance to free up resources
if (activeHowl) { if (activeHowl) {
activeHowl.unload(); activeHowl.unload();
@ -532,7 +556,7 @@ export function TTSProvider({ children }: { children: ReactNode }) {
} }
}, },
onloaderror: (id, error) => { onloaderror: (id, error) => {
console.error('Error loading audio:', error); console.warn('Error loading audio:', error);
setIsProcessing(false); setIsProcessing(false);
setActiveHowl(null); setActiveHowl(null);
URL.revokeObjectURL(audioUrl); URL.revokeObjectURL(audioUrl);
@ -546,15 +570,15 @@ export function TTSProvider({ children }: { children: ReactNode }) {
howl.unload(); // Ensure cleanup on stop howl.unload(); // Ensure cleanup on stop
} }
}); });
setActiveHowl(howl); setActiveHowl(howl);
howl.play(); howl.play();
} catch (error) { } catch (error) {
console.error('Error playing TTS:', error); console.error('Error playing TTS:', error);
setActiveHowl(null); setActiveHowl(null);
setIsProcessing(false); setIsProcessing(false);
toast.error('Failed to process audio. Skipping problematic sentence.', { toast.error('Failed to process audio. Skipping problematic sentence.', {
id: 'tts-processing-error', id: 'tts-processing-error',
style: { style: {
@ -563,7 +587,7 @@ export function TTSProvider({ children }: { children: ReactNode }) {
}, },
duration: 3000, duration: 3000,
}); });
advance(); // Skip problematic sentence advance(); // Skip problematic sentence
} }
}, [isPlaying, processSentence, advance, activeHowl]); }, [isPlaying, processSentence, advance, activeHowl]);
@ -604,10 +628,10 @@ export function TTSProvider({ children }: { children: ReactNode }) {
// Start playing current sentence // Start playing current sentence
playAudio(); playAudio();
// Start preloading next sentence in parallel // Start preloading next sentence in parallel
preloadNextAudio(); preloadNextAudio();
return () => { return () => {
// Only abort if we're actually stopping playback // Only abort if we're actually stopping playback
if (!isPlaying) { if (!isPlaying) {
@ -648,7 +672,7 @@ export function TTSProvider({ children }: { children: ReactNode }) {
*/ */
const stopAndPlayFromIndex = useCallback((index: number) => { const stopAndPlayFromIndex = useCallback((index: number) => {
abortAudio(); abortAudio();
setCurrentIndex(index); setCurrentIndex(index);
setIsPlaying(true); setIsPlaying(true);
}, [abortAudio]); }, [abortAudio]);
@ -660,19 +684,19 @@ export function TTSProvider({ children }: { children: ReactNode }) {
*/ */
const setSpeedAndRestart = useCallback((newSpeed: number) => { const setSpeedAndRestart = useCallback((newSpeed: number) => {
const wasPlaying = isPlaying; const wasPlaying = isPlaying;
// Set a flag to prevent double audio requests during config update // Set a flag to prevent double audio requests during config update
setIsProcessing(true); setIsProcessing(true);
// First stop any current playback // First stop any current playback
setIsPlaying(false); setIsPlaying(false);
abortAudio(true); // Clear pending requests since speed changed abortAudio(true); // Clear pending requests since speed changed
setActiveHowl(null); setActiveHowl(null);
// Update speed, clear cache, and config // Update speed, clear cache, and config
setSpeed(newSpeed); setSpeed(newSpeed);
audioCache.clear(); audioCache.clear();
// Update config after state changes // Update config after state changes
updateConfigKey('voiceSpeed', newSpeed).then(() => { updateConfigKey('voiceSpeed', newSpeed).then(() => {
setIsProcessing(false); setIsProcessing(false);
@ -690,19 +714,19 @@ export function TTSProvider({ children }: { children: ReactNode }) {
*/ */
const setVoiceAndRestart = useCallback((newVoice: string) => { const setVoiceAndRestart = useCallback((newVoice: string) => {
const wasPlaying = isPlaying; const wasPlaying = isPlaying;
// Set a flag to prevent double audio requests during config update // Set a flag to prevent double audio requests during config update
setIsProcessing(true); setIsProcessing(true);
// First stop any current playback // First stop any current playback
setIsPlaying(false); setIsPlaying(false);
abortAudio(true); // Clear pending requests since voice changed abortAudio(true); // Clear pending requests since voice changed
setActiveHowl(null); setActiveHowl(null);
// Update voice, clear cache, and config // Update voice, clear cache, and config
setVoice(newVoice); setVoice(newVoice);
audioCache.clear(); audioCache.clear();
// Update config after state changes // Update config after state changes
updateConfigKey('voice', newVoice).then(() => { updateConfigKey('voice', newVoice).then(() => {
setIsProcessing(false); setIsProcessing(false);