- OpenAI-compatible API on localhost:9998 - TTS → RouterAI /v1/audio/speech (Google Gemini Flash) - STT → RouterAI → whisper.cpp fallback - Health endpoint, CORS, config from openclaw.json - systemd service unit
273 lines
9.9 KiB
JavaScript
273 lines
9.9 KiB
JavaScript
#!/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`);
|
|
});
|