export const dynamic = 'force-dynamic' export const runtime = 'nodejs' import { NextResponse } from 'next/server' import * as https from 'node:https' import { Readable } from 'node:stream' import { HttpsProxyAgent } from 'https-proxy-agent' const ELEVENLABS_HOST = 'api.elevenlabs.io' const DEFAULT_MODEL = 'eleven_flash_v2_5' // ElevenLabs blocks Russian IPs at Cloudflare level; route via non-RU proxy // (set ELEVENLABS_PROXY=http://192.168.31.103:8888 in tablet.env). // Falls back to HTTPS_PROXY / HTTP_PROXY if set. function getProxyAgent(): HttpsProxyAgent | null { const proxy = process.env.ELEVENLABS_PROXY || process.env.HTTPS_PROXY || process.env.HTTP_PROXY || '' return proxy ? new HttpsProxyAgent(proxy) : null } function getVoiceId(agent: string | undefined): string | null { if (agent === 'lusya') return process.env.LUSYA_TTS_VOICE || null return process.env.COSMO_TTS_VOICE || null } interface UpstreamResult { status: number body: Readable | null error?: string } function requestElevenLabs(voiceId: string, payload: string, apiKey: string): Promise { return new Promise((resolve) => { const options: https.RequestOptions = { host: ELEVENLABS_HOST, path: `/v1/text-to-speech/${encodeURIComponent(voiceId)}/stream?output_format=mp3_44100_64`, method: 'POST', headers: { 'xi-api-key': apiKey, Accept: 'audio/mpeg', 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload).toString(), }, timeout: 30000, } const proxyAgent = getProxyAgent() if (proxyAgent) options.agent = proxyAgent const req = https.request(options, (res) => { if ((res.statusCode || 0) !== 200) { let errText = '' res.setEncoding('utf8') res.on('data', (chunk: string) => { errText += chunk }) res.on('end', () => resolve({ status: res.statusCode || 0, body: null, error: errText.slice(0, 500) }) ) return } resolve({ status: 200, body: res }) }) req.on('timeout', () => req.destroy(new Error('elevenlabs_timeout'))) req.on('error', (e) => resolve({ status: 0, body: null, error: e.message })) req.write(payload) req.end() }) } export async function POST(req: Request) { const apiKey = process.env.ELEVENLABS_API_KEY if (!apiKey) { return NextResponse.json({ error: 'tts_not_configured' }, { status: 503 }) } const body = await req.json().catch(() => null) const text = typeof body?.text === 'string' ? body.text.trim() : '' const agent = typeof body?.agent === 'string' ? body.agent : 'cosmo' if (!text) { return NextResponse.json({ error: 'text required' }, { status: 400 }) } if (text.length > 4000) { return NextResponse.json({ error: 'text too long (>4000)' }, { status: 400 }) } const voiceId = getVoiceId(agent) if (!voiceId) { return NextResponse.json({ error: `no voice configured for agent=${agent}` }, { status: 503 }) } const model = process.env.ELEVENLABS_MODEL || DEFAULT_MODEL const payload = JSON.stringify({ text, model_id: model, voice_settings: { stability: 0.45, similarity_boost: 0.75, style: 0.25, use_speaker_boost: true, }, }) const result = await requestElevenLabs(voiceId, payload, apiKey) if (result.status !== 200 || !result.body) { return NextResponse.json( { error: `elevenlabs_${result.status || 'fetch_failed'}`, detail: result.error || '' }, { status: 502 } ) } // Node Readable -> Web ReadableStream for Next.js Response const webStream = Readable.toWeb(result.body) as unknown as ReadableStream return new Response(webStream, { headers: { 'Content-Type': 'audio/mpeg', 'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no', }, }) }