153 lines
5.1 KiB
TypeScript
153 lines
5.1 KiB
TypeScript
/**
|
|
* Single TTS Generation API
|
|
*
|
|
* Generates TTS audio for a single text string and returns base64-encoded audio.
|
|
* Called by the client in parallel for each speech action after a scene is generated.
|
|
*
|
|
* POST /api/generate/tts
|
|
*/
|
|
|
|
import { NextRequest } from 'next/server';
|
|
import { generateTTS, TTSRateLimitError } from '@/lib/audio/tts-providers';
|
|
import { recordGenerationUsage } from '@/lib/server/usage-storage';
|
|
import {
|
|
isServerConfiguredProvider,
|
|
isServerTTSProviderDisabled,
|
|
resolveTTSApiKey,
|
|
resolveTTSBaseUrl,
|
|
resolveTTSModel,
|
|
} from '@/lib/server/provider-config';
|
|
import type { TTSProviderId } from '@/lib/audio/types';
|
|
import { createLogger } from '@/lib/logger';
|
|
import { apiError, apiSuccess } from '@/lib/server/api-response';
|
|
import { validateUrlForSSRF } from '@/lib/server/ssrf-guard';
|
|
import { VOXCPM_AUTO_VOICE_ID, VOXCPM_TTS_PROVIDER_ID } from '@/lib/audio/voxcpm';
|
|
import { providerAccessApiError } from '@/lib/server/provider-access-response';
|
|
|
|
const log = createLogger('TTS API');
|
|
|
|
export const maxDuration = 30;
|
|
|
|
export async function POST(req: NextRequest) {
|
|
let ttsProviderId: string | undefined;
|
|
let ttsVoice: string | undefined;
|
|
let audioId: string | undefined;
|
|
try {
|
|
const body = await req.json();
|
|
const { text, ttsModelId, ttsSpeed, ttsApiKey, ttsBaseUrl, ttsProviderOptions } = body as {
|
|
text: string;
|
|
audioId: string;
|
|
ttsProviderId: TTSProviderId;
|
|
ttsModelId?: string;
|
|
ttsVoice: string;
|
|
ttsSpeed?: number;
|
|
ttsApiKey?: string;
|
|
ttsBaseUrl?: string;
|
|
ttsProviderOptions?: Record<string, unknown>;
|
|
};
|
|
ttsProviderId = body.ttsProviderId;
|
|
ttsVoice = body.ttsVoice;
|
|
audioId = body.audioId;
|
|
|
|
// Validate required fields
|
|
if (!text || !audioId || !ttsProviderId || !ttsVoice) {
|
|
return apiError(
|
|
'MISSING_REQUIRED_FIELD',
|
|
400,
|
|
'Missing required fields: text, audioId, ttsProviderId, ttsVoice',
|
|
);
|
|
}
|
|
|
|
// Reject browser-native TTS — must be handled client-side
|
|
if (ttsProviderId === 'browser-native-tts') {
|
|
return apiError('INVALID_REQUEST', 400, 'browser-native-tts must be handled client-side');
|
|
}
|
|
|
|
// Enforce server precedence: a force-disabled provider is off for everyone,
|
|
// regardless of any client key/selection (#665).
|
|
if (isServerTTSProviderDisabled(ttsProviderId)) {
|
|
return apiError('PROVIDER_DISABLED', 403, 'This TTS provider is disabled by the server');
|
|
}
|
|
|
|
const voxcpmVoicePrompt =
|
|
typeof ttsProviderOptions?.voicePrompt === 'string' ? ttsProviderOptions.voicePrompt : '';
|
|
const voxcpmRegisteredVoiceId =
|
|
typeof ttsProviderOptions?.registeredVoiceId === 'string'
|
|
? ttsProviderOptions.registeredVoiceId
|
|
: '';
|
|
if (
|
|
ttsProviderId === VOXCPM_TTS_PROVIDER_ID &&
|
|
ttsVoice === VOXCPM_AUTO_VOICE_ID &&
|
|
!voxcpmVoicePrompt.trim() &&
|
|
!voxcpmRegisteredVoiceId.trim()
|
|
) {
|
|
return apiError(
|
|
'VOXCPM_AUTO_VOICE_REQUIRES_CONTEXT',
|
|
400,
|
|
'VoxCPM Auto Voice requires agent context',
|
|
);
|
|
}
|
|
|
|
// Managed providers are admin-owned: ignore any client-sent key/baseUrl.
|
|
const managed = isServerConfiguredProvider('tts', ttsProviderId);
|
|
const clientBaseUrl = managed ? undefined : ttsBaseUrl || undefined;
|
|
if (clientBaseUrl) {
|
|
const ssrfError = await validateUrlForSSRF(clientBaseUrl);
|
|
if (ssrfError) {
|
|
return apiError('INVALID_URL', 403, ssrfError);
|
|
}
|
|
}
|
|
|
|
const apiKey = resolveTTSApiKey(ttsProviderId, managed ? undefined : ttsApiKey || undefined);
|
|
const baseUrl = resolveTTSBaseUrl(ttsProviderId, clientBaseUrl);
|
|
|
|
// Build TTS config (managed providers may pin the model server-side)
|
|
const config = {
|
|
providerId: ttsProviderId as TTSProviderId,
|
|
modelId: resolveTTSModel(ttsProviderId, ttsModelId),
|
|
voice: ttsVoice,
|
|
speed: ttsSpeed ?? 1.0,
|
|
apiKey,
|
|
baseUrl,
|
|
providerOptions: ttsProviderOptions,
|
|
};
|
|
|
|
log.info(
|
|
`Generating TTS: provider=${ttsProviderId}, model=${config.modelId || 'default'}, voice=${ttsVoice}, ` +
|
|
`registeredVoiceId=${voxcpmRegisteredVoiceId || 'none'}, audioId=${audioId}, textLen=${text.length}`,
|
|
);
|
|
|
|
// Generate audio
|
|
const { audio, format } = await generateTTS(config, text);
|
|
|
|
void recordGenerationUsage({
|
|
kind: 'tts',
|
|
unit: 'character',
|
|
providerId: ttsProviderId,
|
|
modelId: config.modelId,
|
|
quantity: text.length,
|
|
});
|
|
|
|
// Convert to base64
|
|
const base64 = Buffer.from(audio).toString('base64');
|
|
|
|
return apiSuccess({ audioId, base64, format });
|
|
} catch (error) {
|
|
log.error(
|
|
`TTS generation failed [provider=${ttsProviderId ?? 'unknown'}, voice=${ttsVoice ?? 'unknown'}, audioId=${audioId ?? 'unknown'}]:`,
|
|
error,
|
|
);
|
|
const providerAccessError = providerAccessApiError(error);
|
|
if (providerAccessError) return providerAccessError;
|
|
|
|
if (error instanceof TTSRateLimitError) {
|
|
return apiError('RATE_LIMITED', 429, error.message);
|
|
}
|
|
return apiError(
|
|
'GENERATION_FAILED',
|
|
500,
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
}
|
|
}
|