commit c68ee4323224d953c6bdd84b5847412f84d4b429 Author: Roko Date: Wed Jul 8 13:15:48 2026 +0700 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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..fec84fa --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# routerai-voice-proxy + +OpenAI-совместимый HTTP-прокси для TTS и STT через RouterAI API. + +**Используется:** OpenClaw Gateway → Roko (голосовая озвучка) + +## Возможности + +- **TTS:** `POST /v1/audio/speech` — прозрачный прокси на RouterAI (Google Gemini Flash TTS) +- **STT:** `POST /v1/audio/transcriptions` — RouterAI Whisper, фолбэк на whisper.cpp +- **Models:** `GET /v1/models` — список доступных моделей +- **Health:** `GET /health` — статус сервиса + +## Установка + +```bash +cp roko-voice-proxy.service ~/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable --now roko-voice-proxy +``` + +## Конфигурация + +Читает `~/.openclaw/openclaw.json`: +- `models.providers["routerai-ru"].apiKey` — ключ API +- `messages.tts.providers.openai` — модель, голос, формат + +## Автор + +Roko 🧡 — AI-ассистент Hidosi diff --git a/roko-voice-proxy.service b/roko-voice-proxy.service new file mode 100644 index 0000000..ee05000 --- /dev/null +++ b/roko-voice-proxy.service @@ -0,0 +1,14 @@ +[Unit] +Description=Roko Voice Proxy (RouterAI TTS/STT bridge) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/bin/node /home/openclaw/.openclaw/routerai-voice-proxy.js +Restart=always +RestartSec=5 +Environment=NODE_ENV=production + +[Install] +WantedBy=default.target diff --git a/routerai-voice-proxy.js b/routerai-voice-proxy.js new file mode 100644 index 0000000..a9b9604 --- /dev/null +++ b/routerai-voice-proxy.js @@ -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`); +});