diff --git a/app/api/voice/tts/route.ts b/app/api/voice/tts/route.ts index 0979241..a686aed 100644 --- a/app/api/voice/tts/route.ts +++ b/app/api/voice/tts/route.ts @@ -2,15 +2,74 @@ 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_BASE = 'https://api.elevenlabs.io/v1' +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) { @@ -35,37 +94,30 @@ export async function POST(req: Request) { const model = process.env.ELEVENLABS_MODEL || DEFAULT_MODEL - const upstream = await fetch( - `${ELEVENLABS_BASE}/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', - }, - body: JSON.stringify({ - text, - model_id: model, - voice_settings: { - stability: 0.45, - similarity_boost: 0.75, - style: 0.25, - use_speaker_boost: true, - }, - }), - } - ) + const payload = JSON.stringify({ + text, + model_id: model, + voice_settings: { + stability: 0.45, + similarity_boost: 0.75, + style: 0.25, + use_speaker_boost: true, + }, + }) - if (!upstream.ok || !upstream.body) { - const errText = await upstream.text().catch(() => '') + const result = await requestElevenLabs(voiceId, payload, apiKey) + + if (result.status !== 200 || !result.body) { return NextResponse.json( - { error: `elevenlabs_${upstream.status}`, detail: errText.slice(0, 300) }, + { error: `elevenlabs_${result.status || 'fetch_failed'}`, detail: result.error || '' }, { status: 502 } ) } - return new Response(upstream.body, { + // 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', diff --git a/package-lock.json b/package-lock.json index 09cf431..cce65ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "clsx": "^2.1.1", "framer-motion": "^11.1.7", "googleapis": "^171.4.0", + "https-proxy-agent": "^7.0.6", "lucide-react": "^0.376.0", "next": "14.2.3", "react": "^18", diff --git a/package.json b/package.json index 9b81901..2942fdc 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "clsx": "^2.1.1", "framer-motion": "^11.1.7", "googleapis": "^171.4.0", + "https-proxy-agent": "^7.0.6", "lucide-react": "^0.376.0", "next": "14.2.3", "react": "^18",