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