178 lines
5.9 KiB
TypeScript
178 lines
5.9 KiB
TypeScript
// Learner voice playback — POST /api/tts
|
|
//
|
|
// Synthesizes an answer into audio with a content-addressed cache: the same
|
|
// text+voice+provider always maps to the same file, so repeated answers (and
|
|
// repeated learners asking the same thing) cost one synthesis. Falls back to
|
|
// browser speech synthesis client-side when this endpoint is unavailable.
|
|
//
|
|
// Provider resolution mirrors `/api/generate/tts` (server-configured provider
|
|
// wins, client key ignored for managed providers). Default provider:
|
|
// `QA_TTS_PROVIDER` env, else the first server-configured TTS provider.
|
|
|
|
import { promises as fs } from 'fs';
|
|
import path from 'path';
|
|
import { createHash } from 'crypto';
|
|
import { type NextRequest } from 'next/server';
|
|
import { apiError, API_ERROR_CODES } from '@/lib/server/api-response';
|
|
import { generateTTS } 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 { createSlidingWindowLimiter, clientIp } from '@/lib/qa/rate-limit';
|
|
import { providerAccessApiError } from '@/lib/server/provider-access-response';
|
|
|
|
const log = createLogger('QA TTS API');
|
|
|
|
export const maxDuration = 60;
|
|
|
|
const TTS_MAX_REQUESTS_PER_MIN = Number(process.env.TTS_RATE_LIMIT_PER_MIN ?? 30);
|
|
const ttsLimiter = createSlidingWindowLimiter({
|
|
windowMs: 60_000,
|
|
max: TTS_MAX_REQUESTS_PER_MIN,
|
|
});
|
|
|
|
const TTS_MAX_TEXT_CHARS = 2000;
|
|
const TTS_CACHE_DIR = process.env.TTS_CACHE_DIR ?? path.join(process.cwd(), 'data', 'tts-cache');
|
|
|
|
const KNOWN_TTS_PROVIDERS: TTSProviderId[] = [
|
|
'openai-tts',
|
|
'azure-tts',
|
|
'glm-tts',
|
|
'qwen-tts',
|
|
'voxcpm-tts',
|
|
];
|
|
|
|
/** Resolve the default TTS provider: env override, else first configured. */
|
|
function resolveQaTtsProvider(): TTSProviderId | undefined {
|
|
const configured = process.env.QA_TTS_PROVIDER;
|
|
if (configured) return configured as TTSProviderId;
|
|
return KNOWN_TTS_PROVIDERS.find((id) => isServerConfiguredProvider('tts', id));
|
|
}
|
|
|
|
interface TtsCacheKey {
|
|
providerId: string;
|
|
voice: string;
|
|
text: string;
|
|
}
|
|
|
|
function ttsCacheKey(input: TtsCacheKey): string {
|
|
return createHash('sha256')
|
|
.update(`${input.providerId}|${input.voice}|${input.text}`)
|
|
.digest('hex');
|
|
}
|
|
|
|
function ttsCachePath(key: string, format: string): string {
|
|
return path.join(TTS_CACHE_DIR, `${key}.${format}`);
|
|
}
|
|
|
|
export async function POST(req: NextRequest) {
|
|
try {
|
|
const body = (await req.json()) as { text?: string; voice?: string; providerId?: string };
|
|
const text = typeof body.text === 'string' ? body.text.trim() : '';
|
|
if (!text || text.length > TTS_MAX_TEXT_CHARS) {
|
|
return apiError(
|
|
API_ERROR_CODES.MISSING_REQUIRED_FIELD,
|
|
400,
|
|
`text is required (1..${TTS_MAX_TEXT_CHARS} chars)`,
|
|
);
|
|
}
|
|
|
|
const ip = clientIp(req);
|
|
const limit = ttsLimiter.check(ip);
|
|
if (!limit.allowed) {
|
|
return apiError(
|
|
API_ERROR_CODES.RATE_LIMITED,
|
|
429,
|
|
`TTS rate limit exceeded; retry in ${Math.ceil(limit.retryAfterMs / 1000)}s`,
|
|
);
|
|
}
|
|
|
|
// Provider resolution: explicit (server-validated) or the server default.
|
|
const providerId =
|
|
(typeof body.providerId === 'string' && body.providerId) || resolveQaTtsProvider();
|
|
if (!providerId) {
|
|
return apiError(
|
|
API_ERROR_CODES.PROVIDER_DISABLED,
|
|
503,
|
|
'No TTS provider configured; set QA_TTS_PROVIDER or configure a TTS provider server-side',
|
|
);
|
|
}
|
|
if (isServerTTSProviderDisabled(providerId)) {
|
|
return apiError(API_ERROR_CODES.PROVIDER_DISABLED, 403, 'This TTS provider is disabled');
|
|
}
|
|
|
|
const voice =
|
|
typeof body.voice === 'string' && body.voice.trim() ? body.voice.trim() : 'default';
|
|
const apiKey = resolveTTSApiKey(providerId);
|
|
const baseUrl = resolveTTSBaseUrl(providerId);
|
|
const config = {
|
|
providerId: providerId as TTSProviderId,
|
|
modelId: resolveTTSModel(providerId),
|
|
voice,
|
|
speed: 1.0,
|
|
apiKey,
|
|
baseUrl,
|
|
};
|
|
|
|
// Content-addressed cache: hit → stream the file, miss → synthesize + write.
|
|
const cacheKey = ttsCacheKey({ providerId, voice, text });
|
|
const cacheFile = ttsCachePath(cacheKey, 'mp3');
|
|
try {
|
|
const cached = await fs.readFile(cacheFile);
|
|
return cachedAudioResponse(cached, 'audio/mpeg', cacheFile);
|
|
} catch {
|
|
// cache miss → synthesize below
|
|
}
|
|
|
|
const { audio, format } = await generateTTS(config, text);
|
|
void recordGenerationUsage({
|
|
kind: 'tts',
|
|
unit: 'character',
|
|
providerId,
|
|
modelId: config.modelId,
|
|
quantity: text.length,
|
|
});
|
|
|
|
const mime = format === 'wav' ? 'audio/wav' : 'audio/mpeg';
|
|
await fs.mkdir(TTS_CACHE_DIR, { recursive: true }).catch(() => undefined);
|
|
await fs.writeFile(cacheFile, audio).catch((error) => {
|
|
log.warn('TTS cache write failed (serving uncached):', error);
|
|
});
|
|
return cachedAudioResponse(audio, mime, cacheFile);
|
|
} catch (error) {
|
|
log.error('QA TTS failed:', error);
|
|
const providerAccessError = providerAccessApiError(error);
|
|
if (providerAccessError) return providerAccessError;
|
|
|
|
return apiError(
|
|
API_ERROR_CODES.INTERNAL_ERROR,
|
|
500,
|
|
'TTS synthesis failed',
|
|
error instanceof Error ? error.message : undefined,
|
|
);
|
|
}
|
|
}
|
|
|
|
function cachedAudioResponse(
|
|
bytes: Uint8Array | Buffer,
|
|
mime: string,
|
|
cacheFile: string,
|
|
): Response {
|
|
return new Response(bytes as unknown as BodyInit, {
|
|
headers: {
|
|
'Content-Type': mime,
|
|
'Content-Length': String(bytes.byteLength),
|
|
// Content-addressed → cacheable forever by intermediaries and the client.
|
|
'Cache-Control': 'public, max-age=31536000, immutable',
|
|
ETag: `"${path.basename(cacheFile)}"`,
|
|
},
|
|
});
|
|
}
|