Skip to content

Commit 0be34a0

Browse files
committed
Re-add AbortController API, fixes duplicate playback
1 parent cd00ad2 commit 0be34a0

File tree

2 files changed

+67
-36
lines changed

2 files changed

+67
-36
lines changed

src/app/api/tts/route.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,26 +17,33 @@ export async function POST(req: NextRequest) {
1717
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
1818
}
1919

20-
// Initialize OpenAI client
20+
// Initialize OpenAI client with abort signal
2121
const openai = new OpenAI({
2222
apiKey: openApiKey,
2323
baseURL: openApiBaseUrl,
2424
});
2525

26-
// Request audio from OpenAI
26+
// Request audio from OpenAI and pass along the abort signal
2727
const response = await openai.audio.speech.create({
2828
model: 'tts-1',
2929
voice: voice as "alloy",
3030
input: text,
3131
speed: speed,
32-
});
32+
}, { signal: req.signal }); // Pass the abort signal to OpenAI client
3333

3434
// Get the audio data as array buffer
35+
// This will also be aborted if the client cancels
3536
const arrayBuffer = await response.arrayBuffer();
3637

3738
// Return audio data with appropriate headers
3839
return new NextResponse(arrayBuffer);
3940
} catch (error) {
41+
// Check if this was an abort error
42+
if (error instanceof Error && error.name === 'AbortError') {
43+
console.log('TTS request aborted by client');
44+
return new Response(null, { status: 499 }); // Use 499 status for client closed request
45+
}
46+
4047
console.error('Error generating TTS:', error);
4148
return NextResponse.json(
4249
{ error: 'Failed to generate audio' },

src/contexts/TTSContext.tsx

Lines changed: 57 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,9 @@ const TTSContext = createContext<TTSContextType | undefined>(undefined);
8989
*/
9090
export function TTSProvider({ children }: { children: ReactNode }) {
9191
// Configuration context consumption
92-
const {
93-
apiKey: openApiKey,
94-
baseUrl: openApiBaseUrl,
92+
const {
93+
apiKey: openApiKey,
94+
baseUrl: openApiBaseUrl,
9595
isLoading: configIsLoading,
9696
voiceSpeed,
9797
voice: configVoice,
@@ -139,6 +139,8 @@ export function TTSProvider({ children }: { children: ReactNode }) {
139139

140140
// Track pending preload requests
141141
const preloadRequests = useRef<Map<string, Promise<string>>>(new Map());
142+
// Track active abort controllers for TTS requests
143+
const activeAbortControllers = useRef<Set<AbortController>>(new Set());
142144

143145
//console.log('page:', currDocPage, 'pages:', currDocPages);
144146

@@ -177,7 +179,15 @@ export function TTSProvider({ children }: { children: ReactNode }) {
177179
activeHowl.unload(); // Ensure Howl instance is fully cleaned up
178180
setActiveHowl(null);
179181
}
182+
180183
if (clearPending) {
184+
// Abort all active TTS requests
185+
console.log('Aborting active TTS requests');
186+
activeAbortControllers.current.forEach(controller => {
187+
controller.abort();
188+
});
189+
activeAbortControllers.current.clear();
190+
// Clear any pending preload requests
181191
preloadRequests.current.clear();
182192
}
183193
}, [activeHowl]);
@@ -189,13 +199,13 @@ export function TTSProvider({ children }: { children: ReactNode }) {
189199
* @param {string | number} location - The target location to navigate to
190200
* @param {boolean} keepPlaying - Whether to maintain playback state
191201
*/
192-
const skipToLocation = useCallback((location: string | number) => {
202+
const skipToLocation = useCallback((location: string | number) => {
193203
// Reset state for new content in correct order
194204
abortAudio();
195205
setCurrentIndex(0);
196206
setSentences([]);
197207
setCurrDocPage(location);
198-
208+
199209
}, [abortAudio]);
200210

201211
/**
@@ -205,29 +215,29 @@ export function TTSProvider({ children }: { children: ReactNode }) {
205215
*/
206216
const advance = useCallback(async (backwards = false) => {
207217
const nextIndex = currentIndex + (backwards ? -1 : 1);
208-
218+
209219
// Handle within current page bounds
210220
if (nextIndex < sentences.length && nextIndex >= 0) {
211221
setCurrentIndex(nextIndex);
212222
return;
213223
}
214-
224+
215225
// For EPUB documents, always try to advance to next/prev section
216226
if (isEPUB && locationChangeHandlerRef.current) {
217227
locationChangeHandlerRef.current(nextIndex >= sentences.length ? 'next' : 'prev');
218228
return;
219229
}
220-
230+
221231
// For PDFs and other documents, check page bounds
222232
if (!isEPUB) {
223233
// Handle next/previous page transitions
224-
if ((nextIndex >= sentences.length && currDocPageNumber < currDocPages!) ||
225-
(nextIndex < 0 && currDocPageNumber > 1)) {
234+
if ((nextIndex >= sentences.length && currDocPageNumber < currDocPages!) ||
235+
(nextIndex < 0 && currDocPageNumber > 1)) {
226236
// Pass wasPlaying to maintain playback state during page turn
227237
skipToLocation(currDocPageNumber + (nextIndex >= sentences.length ? 1 : -1));
228238
return;
229239
}
230-
240+
231241
// Handle end of document (PDF only)
232242
if (nextIndex >= sentences.length && currDocPageNumber >= currDocPages!) {
233243
setIsPlaying(false);
@@ -248,7 +258,7 @@ export function TTSProvider({ children }: { children: ReactNode }) {
248258

249259
// Use advance to handle navigation for both EPUB and PDF
250260
advance();
251-
261+
252262
toast.success(isEPUB ? 'Skipping blank section' : `Skipping blank page ${currDocPageNumber}`, {
253263
id: isEPUB ? `epub-section-skip` : `page-${currDocPageNumber}`,
254264
iconTheme: {
@@ -274,13 +284,13 @@ export function TTSProvider({ children }: { children: ReactNode }) {
274284
const setText = useCallback((text: string, shouldPause = false) => {
275285
// Check for blank section first
276286
if (handleBlankSection(text)) return;
277-
287+
278288
// Keep track of previous state and pause playback
279289
const wasPlaying = isPlaying;
280290
setIsPlaying(false);
281291
abortAudio(true); // Clear pending requests since text is changing
282292
setIsProcessing(true); // Set processing state before text processing starts
283-
293+
284294
console.log('Setting text:', text);
285295
processTextToSentences(text)
286296
.then(newSentences => {
@@ -396,6 +406,10 @@ export function TTSProvider({ children }: { children: ReactNode }) {
396406
try {
397407
console.log('Requesting audio for sentence:', sentence);
398408

409+
// Create an AbortController for this request
410+
const controller = new AbortController();
411+
activeAbortControllers.current.add(controller);
412+
399413
const response = await fetch('/api/tts', {
400414
method: 'POST',
401415
headers: {
@@ -408,8 +422,12 @@ export function TTSProvider({ children }: { children: ReactNode }) {
408422
voice: voice,
409423
speed: speed,
410424
}),
425+
signal: controller.signal,
411426
});
412427

428+
// Remove the controller once the request is complete
429+
activeAbortControllers.current.delete(controller);
430+
413431
if (!response.ok) {
414432
throw new Error('Failed to generate audio');
415433
}
@@ -422,6 +440,12 @@ export function TTSProvider({ children }: { children: ReactNode }) {
422440

423441
return arrayBuffer;
424442
} catch (error) {
443+
// Check if this was an abort error
444+
if (error instanceof Error && error.name === 'AbortError') {
445+
console.log('TTS request aborted:', sentence.substring(0, 20));
446+
return;
447+
}
448+
425449
setIsPlaying(false);
426450
toast.error('Failed to generate audio. Server not responding.', {
427451
id: 'tts-api-error',
@@ -444,7 +468,7 @@ export function TTSProvider({ children }: { children: ReactNode }) {
444468
*/
445469
const processSentence = useCallback(async (sentence: string, preload = false): Promise<string> => {
446470
if (!audioContext) throw new Error('Audio context not initialized');
447-
471+
448472
// Check if there's a pending preload request for this sentence
449473
const pendingRequest = preloadRequests.current.get(sentence);
450474
if (pendingRequest) {
@@ -459,7 +483,7 @@ export function TTSProvider({ children }: { children: ReactNode }) {
459483

460484
// Only set processing state if not preloading
461485
if (!preload) setIsProcessing(true);
462-
486+
463487
// Create the audio processing promise
464488
const processPromise = (async () => {
465489
try {
@@ -500,7 +524,7 @@ export function TTSProvider({ children }: { children: ReactNode }) {
500524
if (!audioUrl) {
501525
throw new Error('No audio URL generated');
502526
}
503-
527+
504528
// Force unload any previous Howl instance to free up resources
505529
if (activeHowl) {
506530
activeHowl.unload();
@@ -532,7 +556,7 @@ export function TTSProvider({ children }: { children: ReactNode }) {
532556
}
533557
},
534558
onloaderror: (id, error) => {
535-
console.error('Error loading audio:', error);
559+
console.warn('Error loading audio:', error);
536560
setIsProcessing(false);
537561
setActiveHowl(null);
538562
URL.revokeObjectURL(audioUrl);
@@ -546,15 +570,15 @@ export function TTSProvider({ children }: { children: ReactNode }) {
546570
howl.unload(); // Ensure cleanup on stop
547571
}
548572
});
549-
573+
550574
setActiveHowl(howl);
551575
howl.play();
552-
576+
553577
} catch (error) {
554578
console.error('Error playing TTS:', error);
555579
setActiveHowl(null);
556580
setIsProcessing(false);
557-
581+
558582
toast.error('Failed to process audio. Skipping problematic sentence.', {
559583
id: 'tts-processing-error',
560584
style: {
@@ -563,7 +587,7 @@ export function TTSProvider({ children }: { children: ReactNode }) {
563587
},
564588
duration: 3000,
565589
});
566-
590+
567591
advance(); // Skip problematic sentence
568592
}
569593
}, [isPlaying, processSentence, advance, activeHowl]);
@@ -604,10 +628,10 @@ export function TTSProvider({ children }: { children: ReactNode }) {
604628

605629
// Start playing current sentence
606630
playAudio();
607-
631+
608632
// Start preloading next sentence in parallel
609633
preloadNextAudio();
610-
634+
611635
return () => {
612636
// Only abort if we're actually stopping playback
613637
if (!isPlaying) {
@@ -648,7 +672,7 @@ export function TTSProvider({ children }: { children: ReactNode }) {
648672
*/
649673
const stopAndPlayFromIndex = useCallback((index: number) => {
650674
abortAudio();
651-
675+
652676
setCurrentIndex(index);
653677
setIsPlaying(true);
654678
}, [abortAudio]);
@@ -660,19 +684,19 @@ export function TTSProvider({ children }: { children: ReactNode }) {
660684
*/
661685
const setSpeedAndRestart = useCallback((newSpeed: number) => {
662686
const wasPlaying = isPlaying;
663-
687+
664688
// Set a flag to prevent double audio requests during config update
665689
setIsProcessing(true);
666-
690+
667691
// First stop any current playback
668692
setIsPlaying(false);
669693
abortAudio(true); // Clear pending requests since speed changed
670694
setActiveHowl(null);
671-
695+
672696
// Update speed, clear cache, and config
673697
setSpeed(newSpeed);
674698
audioCache.clear();
675-
699+
676700
// Update config after state changes
677701
updateConfigKey('voiceSpeed', newSpeed).then(() => {
678702
setIsProcessing(false);
@@ -690,19 +714,19 @@ export function TTSProvider({ children }: { children: ReactNode }) {
690714
*/
691715
const setVoiceAndRestart = useCallback((newVoice: string) => {
692716
const wasPlaying = isPlaying;
693-
717+
694718
// Set a flag to prevent double audio requests during config update
695719
setIsProcessing(true);
696-
720+
697721
// First stop any current playback
698722
setIsPlaying(false);
699723
abortAudio(true); // Clear pending requests since voice changed
700724
setActiveHowl(null);
701-
725+
702726
// Update voice, clear cache, and config
703727
setVoice(newVoice);
704728
audioCache.clear();
705-
729+
706730
// Update config after state changes
707731
updateConfigKey('voice', newVoice).then(() => {
708732
setIsProcessing(false);

0 commit comments

Comments
 (0)