From f5cd3b262ae40cc0973b507d54ab9240fe262422 Mon Sep 17 00:00:00 2001 From: Richard R Date: Sun, 22 Feb 2026 03:08:16 -0700 Subject: [PATCH] feat: Refine TTS retry logic to handle client-side transport failures and enhance voice management within the TTS context. --- src/app/api/tts/route.ts | 7 ++++++ src/components/player/VoicesControl.tsx | 4 +++- src/contexts/EPUBContext.tsx | 14 +++++++----- src/contexts/PDFContext.tsx | 18 ++++++++------- src/contexts/TTSContext.tsx | 30 ++++++++++++++++++------- src/hooks/audio/useVoiceManagement.ts | 9 ++++++-- src/lib/client/api/audiobooks.ts | 20 +++++++++++++++++ 7 files changed, 77 insertions(+), 25 deletions(-) diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts index 156c788..4e97091 100644 --- a/src/app/api/tts/route.ts +++ b/src/app/api/tts/route.ts @@ -13,6 +13,9 @@ import { isAuthEnabled } from '@/lib/server/auth/config'; import { getClientIp } from '@/lib/server/rate-limit/request-ip'; import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id'; +export const runtime = 'nodejs'; +export const maxDuration = 60; + function attachDeviceIdCookie(response: NextResponse, deviceId: string | null, didCreate: boolean) { if (didCreate && deviceId) { setDeviceIdCookie(response, deviceId); @@ -237,6 +240,10 @@ export async function POST(req: NextRequest) { const openai = new OpenAI({ apiKey: openApiKey, baseURL: openApiBaseUrl, + // Keep retry policy centralized in this route's fetchTTSBufferWithRetry. + maxRetries: 0, + // Keep upstream timeout below route max duration so we can return a controlled error. + timeout: Number(process.env.TTS_UPSTREAM_TIMEOUT_MS ?? 45_000), }); const normalizedVoice = ( diff --git a/src/components/player/VoicesControl.tsx b/src/components/player/VoicesControl.tsx index d854598..443e8cf 100644 --- a/src/components/player/VoicesControl.tsx +++ b/src/components/player/VoicesControl.tsx @@ -1,6 +1,7 @@ 'use client'; import { useConfig } from '@/contexts/ConfigContext'; +import { useTTS } from '@/contexts/TTSContext'; import { useCallback } from 'react'; import { VoicesControlBase } from '@/components/player/VoicesControlBase'; @@ -8,7 +9,8 @@ export const VoicesControl = ({ availableVoices, setVoiceAndRestart }: { availableVoices: string[]; setVoiceAndRestart: (voice: string) => void; }) => { - const { voice, ttsModel, ttsProvider } = useConfig(); + const { ttsModel, ttsProvider } = useConfig(); + const { voice } = useTTS(); const onChangeVoice = useCallback((nextVoice: string) => setVoiceAndRestart(nextVoice), [setVoiceAndRestart]); return ( diff --git a/src/contexts/EPUBContext.tsx b/src/contexts/EPUBContext.tsx index 54530d9..f2bb968 100644 --- a/src/contexts/EPUBContext.tsx +++ b/src/contexts/EPUBContext.tsx @@ -457,11 +457,12 @@ export function EPUBProvider({ children }: { children: ReactNode }) { instructions: effectiveModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined }; + // Allow one narrow client retry for transient browser->/api/tts transport failures. + // HTTP failures are not retried client-side. const retryOptions: TTSRetryOptions = { maxRetries: 2, - initialDelay: 5000, - maxDelay: 10000, - backoffFactor: 2 + initialDelay: 300, + maxDelay: 300, }; const audioBuffer = await withRetry( @@ -623,11 +624,12 @@ export function EPUBProvider({ children }: { children: ReactNode }) { instructions: effectiveModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined }; + // Allow one narrow client retry for transient browser->/api/tts transport failures. + // HTTP failures are not retried client-side. const retryOptions: TTSRetryOptions = { maxRetries: 2, - initialDelay: 5000, - maxDelay: 10000, - backoffFactor: 2 + initialDelay: 300, + maxDelay: 300, }; const audioBuffer = await withRetry( diff --git a/src/contexts/PDFContext.tsx b/src/contexts/PDFContext.tsx index 0df17af..a9241c6 100644 --- a/src/contexts/PDFContext.tsx +++ b/src/contexts/PDFContext.tsx @@ -513,11 +513,12 @@ export function PDFProvider({ children }: { children: ReactNode }) { instructions: effectiveModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined }; + // Allow one narrow client retry for transient browser->/api/tts transport failures. + // HTTP failures are not retried client-side. const retryOptions: TTSRetryOptions = { - maxRetries: 3, - initialDelay: 1000, - maxDelay: 5000, - backoffFactor: 2 + maxRetries: 2, + initialDelay: 300, + maxDelay: 300, }; try { @@ -686,11 +687,12 @@ export function PDFProvider({ children }: { children: ReactNode }) { instructions: effectiveModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined }; + // Allow one narrow client retry for transient browser->/api/tts transport failures. + // HTTP failures are not retried client-side. const retryOptions: TTSRetryOptions = { - maxRetries: 3, - initialDelay: 1000, - maxDelay: 5000, - backoffFactor: 2 + maxRetries: 2, + initialDelay: 300, + maxDelay: 300, }; const audioBuffer: TTSAudioBuffer = await withRetry( diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index 23e0a47..b62c402 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -67,6 +67,7 @@ declare global { */ interface TTSContextType extends TTSPlaybackState { // Voice settings + voice: string; availableVoices: string[]; // Alignment metadata for the current sentence @@ -905,23 +906,33 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement if (availableVoices.length > 0) { // Allow Kokoro multi-voice strings (e.g., "voice1(0.5)+voice2(0.5)") for any provider const isKokoro = isKokoroModel(configTTSModel); + const fallbackVoice = configVoice || availableVoices[0]; if (isKokoro) { // If Kokoro and we have any voice string (including plus/weights), don't override it. - // Only default when voice is empty. + // Only default when local voice is empty. if (!voice) { - setVoice(availableVoices[0]); + setVoice(fallbackVoice); } return; } - if (!voice || !availableVoices.includes(voice)) { + // For non-Kokoro, only force a fallback when there is no active local voice. + // If a persisted config voice exists, keep it rather than overriding from a + // potentially stale in-flight voices response during reload. + if (!voice) { + console.log(`Voice "${voice || '(empty)'}" not set. Using "${fallbackVoice}"`); + setVoice(fallbackVoice); + return; + } + + if (!configVoice && !availableVoices.includes(voice)) { console.log(`Voice "${voice || '(empty)'}" not found in available voices. Using "${availableVoices[0]}"`); setVoice(availableVoices[0]); // Don't save to config - just use it temporarily until user explicitly selects one } } - }, [availableVoices, voice, configTTSModel]); + }, [availableVoices, voice, configVoice, configTTSModel]); /** * Generates and plays audio for the current sentence @@ -1029,11 +1040,12 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined, }; + // Allow one narrow client retry for transient browser->/api/tts transport failures. + // HTTP failures are not retried client-side. const retryOptions: TTSRetryOptions = { - maxRetries: 3, - initialDelay: 1000, - maxDelay: 5000, - backoffFactor: 2 + maxRetries: 2, + initialDelay: 300, + maxDelay: 300, }; onTTSStart(); @@ -1727,6 +1739,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement currDocPage, currDocPageNumber, currDocPages, + voice, availableVoices, togglePlay, skipForward, @@ -1751,6 +1764,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement currDocPage, currDocPageNumber, currDocPages, + voice, availableVoices, togglePlay, skipForward, diff --git a/src/hooks/audio/useVoiceManagement.ts b/src/hooks/audio/useVoiceManagement.ts index 51a62d2..02c34e2 100644 --- a/src/hooks/audio/useVoiceManagement.ts +++ b/src/hooks/audio/useVoiceManagement.ts @@ -1,6 +1,6 @@ 'use client'; -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef } from 'react'; import { getVoices } from '@/lib/client/api/audiobooks'; const DEFAULT_VOICES = ['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer']; @@ -20,8 +20,10 @@ export function useVoiceManagement( ttsModel: string | undefined ) { const [availableVoices, setAvailableVoices] = useState([]); + const fetchSeqRef = useRef(0); const fetchVoices = useCallback(async () => { + const fetchSeq = ++fetchSeqRef.current; try { console.log('Fetching voices...'); const data = await getVoices({ @@ -31,10 +33,13 @@ export function useVoiceManagement( 'x-tts-model': ttsModel || 'tts-1', 'Content-Type': 'application/json', }); - + + // Ignore stale responses from older provider/model fetches. + if (fetchSeq !== fetchSeqRef.current) return; setAvailableVoices(data.voices || DEFAULT_VOICES); } catch (error) { console.error('Error fetching voices:', error); + if (fetchSeq !== fetchSeqRef.current) return; // Set available voices to default openai voices setAvailableVoices(DEFAULT_VOICES); } diff --git a/src/lib/client/api/audiobooks.ts b/src/lib/client/api/audiobooks.ts index d65a507..3a5c164 100644 --- a/src/lib/client/api/audiobooks.ts +++ b/src/lib/client/api/audiobooks.ts @@ -62,6 +62,26 @@ export const withRetry = async ( break; } + // Narrow client retries to transport-level failures only. + // If we got an HTTP status from /api/tts, do not retry from the client. + // Server-side /api/tts already applies upstream retry logic. + const message = lastError.message.toLowerCase(); + const isTransportFailure = + status === undefined && + ( + lastError instanceof TypeError || + message.includes('failed to fetch') || + message.includes('networkerror') || + message.includes('network request failed') || + message.includes('load failed') || + message.includes('fetch failed') || + message.includes('econnreset') || + message.includes('etimedout') + ); + if (!isTransportFailure) { + break; + } + if (attempt === maxRetries - 1) { break; }