thu-maic--openmaic
154 行
5.2 KiB
TypeScript
154 行
5.2 KiB
TypeScript
/**
|
|
* Auto-voice registration API (provider-neutral).
|
|
*
|
|
* Idempotently ensures an agent's deterministic voice id is registered on the
|
|
* selected TTS provider's backend so later TTS can reference it by id (stable
|
|
* timbre, lean payload). Dispatches to the provider's VoiceRegistrationAdapter;
|
|
* no provider is named here. Folds bootstrap + register + existence-check +
|
|
* register-on-invalid into one call:
|
|
* - client supplies a cached reference clip → (re)register it under voiceId;
|
|
* - else if the voice already exists → no-op;
|
|
* - else synthesize the descriptor once, register, and return the clip so the
|
|
* client can cache it.
|
|
*
|
|
* POST /api/generate/voice
|
|
*/
|
|
|
|
import { NextRequest } from 'next/server';
|
|
import {
|
|
isServerConfiguredProvider,
|
|
isServerTTSProviderDisabled,
|
|
resolveTTSApiKey,
|
|
resolveTTSBaseUrl,
|
|
resolveTTSModel,
|
|
} from '@/lib/server/provider-config';
|
|
import { createLogger } from '@/lib/logger';
|
|
import { apiError, apiSuccess } from '@/lib/server/api-response';
|
|
import { validateUrlForSSRF } from '@/lib/server/ssrf-guard';
|
|
import { normalizeVoiceDesign } from '@/lib/audio/voice-design';
|
|
import {
|
|
getVoiceRegistrationAdapter,
|
|
type VoiceRegistrationConfig,
|
|
} from '@/lib/audio/voice-registration';
|
|
|
|
const log = createLogger('Voice Registration API');
|
|
|
|
export const maxDuration = 30;
|
|
|
|
export async function POST(req: NextRequest) {
|
|
let providerId: string | undefined;
|
|
let voiceId: string | undefined;
|
|
try {
|
|
const body = (await req.json()) as {
|
|
providerId?: string;
|
|
voiceId?: string;
|
|
descriptor?: unknown;
|
|
language?: string;
|
|
referenceAudioBase64?: string;
|
|
mimeType?: string;
|
|
ttsApiKey?: string;
|
|
ttsBaseUrl?: string;
|
|
ttsModelId?: string;
|
|
};
|
|
providerId = typeof body.providerId === 'string' ? body.providerId : undefined;
|
|
voiceId = typeof body.voiceId === 'string' ? body.voiceId.trim() : undefined;
|
|
const design = normalizeVoiceDesign(body.descriptor);
|
|
|
|
if (!providerId) {
|
|
return apiError('MISSING_REQUIRED_FIELD', 400, 'providerId is required');
|
|
}
|
|
if (!voiceId) {
|
|
return apiError('MISSING_REQUIRED_FIELD', 400, 'voiceId is required');
|
|
}
|
|
if (!design && !body.referenceAudioBase64) {
|
|
return apiError(
|
|
'MISSING_REQUIRED_FIELD',
|
|
400,
|
|
'descriptor or referenceAudioBase64 is required',
|
|
);
|
|
}
|
|
|
|
// A server-force-disabled provider is off for everyone (#665), same as the TTS route.
|
|
if (isServerTTSProviderDisabled(providerId)) {
|
|
return apiError('PROVIDER_DISABLED', 403, 'This TTS provider is disabled by the server');
|
|
}
|
|
|
|
const adapter = getVoiceRegistrationAdapter(providerId);
|
|
if (!adapter) {
|
|
return apiError(
|
|
'INVALID_REQUEST',
|
|
400,
|
|
`Provider "${providerId}" does not support voice registration`,
|
|
);
|
|
}
|
|
|
|
// Managed providers are admin-owned: ignore any client-sent key/baseUrl.
|
|
const managed = isServerConfiguredProvider('tts', providerId);
|
|
const clientBaseUrl = managed ? undefined : body.ttsBaseUrl || undefined;
|
|
if (clientBaseUrl) {
|
|
const ssrfError = await validateUrlForSSRF(clientBaseUrl);
|
|
if (ssrfError) {
|
|
return apiError('INVALID_URL', 403, ssrfError);
|
|
}
|
|
}
|
|
|
|
const apiKey = resolveTTSApiKey(providerId, managed ? undefined : body.ttsApiKey || undefined);
|
|
const baseUrl = resolveTTSBaseUrl(providerId, clientBaseUrl);
|
|
if (!baseUrl) {
|
|
return apiError('MISSING_REQUIRED_FIELD', 400, 'TTS base URL is required');
|
|
}
|
|
|
|
const cfg: VoiceRegistrationConfig = {
|
|
baseUrl,
|
|
apiKey,
|
|
model: resolveTTSModel(providerId, body.ttsModelId),
|
|
};
|
|
|
|
// Already registered → no-op (also avoids a redundant re-register when the
|
|
// client offered a cached clip but the voice is still live on the backend).
|
|
if (await adapter.voiceExists(cfg, voiceId)) {
|
|
return apiSuccess({ voiceId, registered: true });
|
|
}
|
|
|
|
// Not present, but the client has the cached reference clip → re-register it
|
|
// (register-on-invalid; preserves the original timbre instead of re-synthesizing).
|
|
if (body.referenceAudioBase64) {
|
|
await adapter.registerVoice(cfg, {
|
|
voiceId,
|
|
referenceAudioBase64: body.referenceAudioBase64,
|
|
mimeType: body.mimeType,
|
|
});
|
|
return apiSuccess({ voiceId, registered: true });
|
|
}
|
|
|
|
// First use → bootstrap-synthesize the descriptor, register, return the clip.
|
|
const clip = await adapter.bootstrapReferenceClip(cfg, {
|
|
design: design!,
|
|
language: body.language,
|
|
});
|
|
await adapter.registerVoice(cfg, {
|
|
voiceId,
|
|
referenceAudioBase64: clip.referenceAudioBase64,
|
|
mimeType: clip.mimeType,
|
|
});
|
|
|
|
log.info(`Registered auto voice ${voiceId} for provider ${providerId}`);
|
|
return apiSuccess({
|
|
voiceId,
|
|
registered: true,
|
|
referenceAudioBase64: clip.referenceAudioBase64,
|
|
mimeType: clip.mimeType,
|
|
});
|
|
} catch (error) {
|
|
log.error(
|
|
`Voice registration failed [provider=${providerId ?? 'unknown'}, voiceId=${voiceId ?? 'unknown'}]:`,
|
|
error,
|
|
);
|
|
return apiError(
|
|
'GENERATION_FAILED',
|
|
500,
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
}
|
|
}
|