feat: Refine TTS retry logic to handle client-side transport failures and enhance voice management within the TTS context.

This commit is contained in:
Richard R 2026-02-22 03:08:16 -07:00
parent cbd8aa6240
commit f5cd3b262a
7 changed files with 77 additions and 25 deletions

View file

@ -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 = (

View file

@ -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 (

View file

@ -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(

View file

@ -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(

View file

@ -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,

View file

@ -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<string[]>([]);
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);
}

View file

@ -62,6 +62,26 @@ export const withRetry = async <T>(
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;
}