#!/usr/bin/env node /** * RouterAI Voice Proxy v2 — теперь с локальным STT через whisper.cpp * * STT (multipart) → 1) пытается routerai, 2) фолбэк на whisper.cpp * TTS (JSON) → прозрачный прокси на routerai */ const http = require('http'); const url = require('url'); const fs = require('fs'); const path = require('path'); const { spawn } = require('child_process'); const https = require('https'); // Конфиг const WHISPER_BIN = '/tmp/whisper.cpp/main'; const WHISPER_MODEL = '/tmp/whisper.cpp/models/ggml-tiny.bin'; const TMP_DIR = '/tmp/whisper_uploads'; const PORT = parseInt(process.env.PORT || '9998'); const configPath = path.join(process.env.HOME || '/home/openclaw', '.openclaw', 'openclaw.json'); const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); const API_KEY = cfg.models?.providers?.['routerai-ru']?.apiKey || process.env.ROUTERAI_API_KEY; const ROUTERAI_BASE = 'https://routerai.ru/api/v1'; fs.mkdirSync(TMP_DIR, { recursive: true }); // ============ Утилиты ============ function collectBody(req) { return new Promise((resolve, reject) => { const chunks = []; req.on('data', chunk => chunks.push(chunk)); req.on('end', () => resolve(Buffer.concat(chunks))); req.on('error', reject); }); } function parseMultipart(body, boundary) { const parts = []; const parts_raw = body.split(`--${boundary}`); for (const part of parts_raw) { if (!part || part.trim() === '' || part.trim() === '--') continue; const headerEnd = part.indexOf('\r\n\r\n'); if (headerEnd === -1) continue; const headersRaw = part.substring(0, headerEnd); let content = part.substring(headerEnd + 4); if (content.endsWith('\r\n')) content = content.slice(0, -2); const contentDisposition = headersRaw.match(/Content-Disposition:\s*form-data;\s*(.+)/i); const contentType = headersRaw.match(/Content-Type:\s*(.+)/i); const params = {}; if (contentDisposition) { for (const p of contentDisposition[1].split(';')) { const m = p.match(/\s*(\w+)\s*=\s*"([^"]+)"/); if (m) params[m[1]] = m[2]; } } parts.push({ name: params.name, filename: params.filename, contentType: contentType ? contentType[1].trim() : null, data: Buffer.from(content, 'binary') }); } return parts; } // ============ Основной сервер ============ const server = http.createServer(async (req, res) => { const parsed = url.parse(req.url, true); const pathname = parsed.pathname; res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', '*'); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } try { const body = await collectBody(req); // ===== GET /v1/models ===== if (pathname === '/v1/models' && req.method === 'GET') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ object: 'list', data: [ { id: 'whisper-1', object: 'model', created: 1710000000, owned_by: 'openai' }, { id: 'gpt-4o-mini-transcribe', object: 'model', created: 1710000000, owned_by: 'openai' }, { id: 'google/gemini-3.1-flash-tts-preview', object: 'model', created: 1710000000, owned_by: 'google' } ] })); return; } // ===== POST /v1/audio/speech (TTS proxy) ===== if (pathname === '/v1/audio/speech' && req.method === 'POST') { let speechBody = body.toString('utf8'); try { const parsed = JSON.parse(speechBody); // Удаляем response_format — routerai не поддерживает mp3/opus if (['mp3','opus','wav','pcm'].includes(parsed.response_format)) { delete parsed.response_format; speechBody = JSON.stringify(parsed); } } catch(e) {} const bodyBuf = Buffer.from(speechBody, 'utf8'); const urlObj = new URL(`${ROUTERAI_BASE}/audio/speech`); const options = { hostname: urlObj.hostname, port: 443, path: urlObj.pathname, method: 'POST', headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': req.headers['content-type'] || 'application/json', 'Content-Length': bodyBuf.length } }; const proxyReq = https.request(options, (proxyRes) => { res.writeHead(proxyRes.statusCode, proxyRes.headers); proxyRes.pipe(res); }); proxyReq.on('error', e => { res.writeHead(502); res.end(JSON.stringify({ error: e.message })); }); proxyReq.write(bodyBuf); proxyReq.end(); return; } // ===== POST /v1/audio/transcriptions (STT) ===== if (pathname === '/v1/audio/transcriptions' && req.method === 'POST') { const isMultipart = req.headers['content-type']?.includes('multipart/form-data'); const boundary = req.headers['content-type']?.match(/boundary=([^;]+)/i)?.[1]; let model = 'whisper-1'; let audioData = null; let audioFormat = 'wav'; if (isMultipart && boundary) { const parts = parseMultipart(body.toString('binary'), boundary); for (const part of parts) { if (part.name === 'model') { model = part.data.toString('utf8').trim(); } else if (part.name === 'file') { audioData = part.data; if (part.filename) { audioFormat = path.extname(part.filename).slice(1).toLowerCase() || 'wav'; } } } } else { // JSON запрос (уже в формате routerai) try { const json = JSON.parse(body.toString('utf8')); model = json.model || model; if (json.input_audio?.data) { audioData = Buffer.from(json.input_audio.data, 'base64'); audioFormat = json.input_audio.format || 'wav'; } } catch(e) {} } if (!audioData) { res.writeHead(400); res.end(JSON.stringify({ error: 'No audio data' })); return; } // Пробуем сначала routerai try { const b64 = audioData.toString('base64'); const payload = JSON.stringify({ model: model, input_audio: { data: b64, format: audioFormat }, language: 'ru' }); const result = await new Promise((resolve, reject) => { const urlObj = new URL(`${ROUTERAI_BASE}/audio/transcriptions`); const opts = { hostname: urlObj.hostname, port: 443, path: urlObj.pathname, method: 'POST', headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } }; const rq = https.request(opts, (rp) => { let d = ''; rp.on('data', c => d += c); rp.on('end', () => { try { const j = JSON.parse(d); if (j.text !== undefined) resolve(j.text); else reject(new Error('No text in response: ' + d.slice(0, 100))); } catch(e) { reject(new Error('Bad JSON: ' + d.slice(0, 100))); } }); }); rq.on('error', reject); rq.write(payload); rq.end(); }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ text: result, model: model, object: 'text' })); console.log(`routerai STT OK: "${result.slice(0, 50)}"`); return; } catch (e) { console.log(`routerai STT failed (${e.message}), falling back to whisper.cpp`); } // Фолбэк: whisper.cpp const tmpFile = path.join(TMP_DIR, `audio_${Date.now()}.${audioFormat}`); fs.writeFileSync(tmpFile, audioData); try { const text = await new Promise((resolve, reject) => { const proc = spawn(WHISPER_BIN, ['-m', WHISPER_MODEL, '-f', tmpFile, '-l', 'ru', '--no-timestamps']); let output = ''; proc.stdout.on('data', d => output += d.toString()); proc.stderr.on('data', d => {}); // Игнорируем stderr (там прогресс) proc.on('close', (code) => { const lines = output.trim().split('\n').filter(l => l.trim()); const lastLine = lines[lines.length - 1] || ''; resolve(lastLine.trim()); }); proc.on('error', reject); }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ text: text, model: 'whisper-cpp-tiny', object: 'text' })); console.log(`whisper.cpp STT OK: "${text.slice(0, 50)}"`); } catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: e.message })); console.error(`whisper.cpp error: ${e.message}`); } finally { try { fs.unlinkSync(tmpFile); } catch(e) {} } return; } // ===== Health ===== if (pathname === '/health' || pathname === '/') { const whisperExists = fs.existsSync(WHISPER_BIN) && fs.existsSync(WHISPER_MODEL); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok', service: 'routerai-voice-proxy', whisper_local: whisperExists, routerai: !!API_KEY })); return; } res.writeHead(404); res.end(JSON.stringify({ error: 'Not found' })); } catch (e) { console.error('Request error:', e); res.writeHead(500); res.end(JSON.stringify({ error: e.message })); } }); server.listen(PORT, '127.0.0.1', () => { const whisperOk = fs.existsSync(WHISPER_BIN) && fs.existsSync(WHISPER_MODEL); console.log(`🟢 RouterAI Voice Proxy v2 — http://127.0.0.1:${PORT}`); console.log(` STT: routerai → ${whisperOk ? 'whisper.cpp (фолбэк)' : 'только routerai'}`); console.log(` TTS: прокси → ${ROUTERAI_BASE}/audio/speech`); });