Re-add AbortController API, fixes duplicate playback
This commit is contained in:
parent
cd00ad2b9b
commit
0be34a09fe
2 changed files with 67 additions and 36 deletions
|
|
@ -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' },
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue