Files
openmaic/OpenMAIC/app/api/tts/route.ts

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