Files
openmaic/OpenMAIC/app/api/generate/tts/route.ts
2026-08-16 14:58:47 +08:00

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),
);
}
}