v2: initial commit — TTS proxy with STT fallback to whisper.cpp

- 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
This commit is contained in:
Roko
2026-07-08 13:15:48 +07:00
commit c68ee43232
3 changed files with 316 additions and 0 deletions
+272
View File
@@ -0,0 +1,272 @@
#!/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`);
});