#!/usr/bin/env node /** * RouterAI Voice Proxy v3 * * Bridges OpenClaw Gateway (OpenAI-compatible API) to RouterAI. * Handles TTS proxying and STT (RouterAI first, whisper.cpp fallback). * * @version 3.0.0 */ const http = require('http'); const https = require('https'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { spawn } = require('child_process'); 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', 10); const MAX_BODY = 50 * 1024 * 1024; const UPSTREAM_TIMEOUT = 30000; const HOME = process.env.HOME || '/home/openclaw'; const CONFIG_PATH = path.join(HOME, '.openclaw', 'openclaw.json'); const LOG_DIR = path.join(HOME, '.openclaw', 'logs'); const LOG_FILE = path.join(LOG_DIR, 'voice-proxy.log'); fs.mkdirSync(LOG_DIR, { recursive: true }); fs.mkdirSync(TMP_DIR, { recursive: true }); const stats = { tts_total: 0, stt_total: 0, errors_total: 0, startTime: Date.now() }; let configCache = null; let configMtime = 0; let configLastCheck = 0; const CONFIG_TTL = 5000; let activeRequests = 0; let shuttingDown = false; let lastProbe = { ok: false, time: 0 }; /** @returns {object} Cached or freshly loaded configuration. */ function loadConfig() { const now = Date.now(); if (now - configLastCheck < CONFIG_TTL && configCache) return configCache; configLastCheck = now; try { const stat = fs.statSync(CONFIG_PATH); if (stat.mtimeMs === configMtime && configCache) return configCache; configMtime = stat.mtimeMs; const data = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); const tts = data.messages?.tts?.providers?.openai || {}; configCache = { model: tts.model || 'google/gemini-3.1-flash-tts-preview', voice: tts.speakerVoice || 'Kore', responseFormat: tts.responseFormat || 'mp3', apiKey: process.env.ROUTERAI_API_KEY || data.models?.providers?.['routerai-ru']?.apiKey || '', baseUrl: process.env.ROUTERAI_BASE_URL || 'https://routerai.ru/api/v1' }; } catch (e) { if (!configCache) { configCache = { model: 'google/gemini-3.1-flash-tts-preview', voice: 'Kore', responseFormat: 'mp3', apiKey: process.env.ROUTERAI_API_KEY || '', baseUrl: process.env.ROUTERAI_BASE_URL || 'https://routerai.ru/api/v1' }; } log('WARN', { message: 'Config read failed, using defaults', error: e.message }); } return configCache; } /** Rotate log file when it exceeds 10MB or crosses a day boundary. */ function rotateLogIfNeeded() { try { if (!fs.existsSync(LOG_FILE)) return; const stat = fs.statSync(LOG_FILE); const now = new Date(); const mtime = new Date(stat.mtime); const sameDay = now.toISOString().slice(0, 10) === mtime.toISOString().slice(0, 10); if (stat.size > 10 * 1024 * 1024 || !sameDay) { const suffix = mtime.toISOString().slice(0, 10); let dest = path.join(LOG_DIR, `voice-proxy-${suffix}.log`); let i = 1; while (fs.existsSync(dest)) dest = path.join(LOG_DIR, `voice-proxy-${suffix}-${i++}.log`); fs.renameSync(LOG_FILE, dest); cleanupOldLogs(); } } catch (e) { console.error('Log rotation error:', e.message); } } /** Delete log files older than 7 days. */ function cleanupOldLogs() { try { const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000; for (const f of fs.readdirSync(LOG_DIR)) { if (!f.endsWith('.log') || f === 'voice-proxy.log') continue; const p = path.join(LOG_DIR, f); if (fs.statSync(p).mtimeMs < cutoff) fs.unlinkSync(p); } } catch (e) { console.error('Log cleanup error:', e.message); } } /** Append a structured JSON log line. */ function log(level, data) { try { rotateLogIfNeeded(); fs.appendFileSync(LOG_FILE, JSON.stringify({ ts: new Date().toISOString(), level, ...data }) + '\n'); } catch (e) { console.error('Logger error:', e.message); } } /** Send a sanitized error response with request tracing. */ function errorResponse(res, status, requestId, clientMessage) { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: clientMessage || 'Internal server error', request_id: requestId })); } /** Collect the request body, rejecting payloads above MAX_BODY. */ function collectBody(req) { return new Promise((resolve, reject) => { const chunks = []; let size = 0; req.on('data', (chunk) => { size += chunk.length; if (size > MAX_BODY) { req.destroy(); reject(new Error('Request body too large')); return; } chunks.push(chunk); }); req.on('end', () => resolve(Buffer.concat(chunks))); req.on('error', reject); }); } /** * Parse multipart/form-data directly from a raw Buffer. * Avoids string conversion that corrupts non-ASCII audio bytes. * * @param {Buffer} body * @param {string} boundary * @returns {Array<{name, filename, contentType, data: Buffer}>} */ function parseMultipart(body, boundary) { const sep = Buffer.from(`--${boundary}`); const crlf = Buffer.from('\r\n'); const crlfcrlf = Buffer.from('\r\n\r\n'); const parts = []; let pos = 0; while (true) { let start = body.indexOf(sep, pos); if (start === -1) break; start += sep.length; if (start + 2 <= body.length && body[start] === 45 && body[start + 1] === 45) break; if (start + 1 < body.length && body[start] === 13 && body[start + 1] === 10) start += 2; const headerEnd = body.indexOf(crlfcrlf, start); if (headerEnd === -1) break; const headersRaw = body.toString('utf8', start, headerEnd); const contentStart = headerEnd + 4; const next = body.indexOf(Buffer.concat([crlf, sep]), contentStart); let contentEnd = next !== -1 ? next : body.length; if (contentEnd >= 2 && body[contentEnd - 2] === 13 && body[contentEnd - 1] === 10) contentEnd -= 2; const params = {}; const cd = headersRaw.match(/Content-Disposition:\s*form-data;\s*(.+)/i); if (cd) { for (const p of cd[1].split(';')) { const m = p.match(/\s*(\w+)\s*=\s*"([^"]+)"/); if (m) params[m[1]] = m[2]; } } const ct = headersRaw.match(/Content-Type:\s*(.+)/i); parts.push({ name: params.name, filename: params.filename, contentType: ct ? ct[1].trim() : null, data: body.subarray(contentStart, contentEnd) }); if (next === -1) break; pos = next + 2; } return parts; } /** Probe RouterAI connectivity with a short timeout. */ async function probeRouterAI() { const cfg = loadConfig(); const now = Date.now(); if (now - lastProbe.time < 5000) return lastProbe.ok; const ok = await new Promise((resolve) => { const urlObj = new URL(cfg.baseUrl); const rq = https.request({ hostname: urlObj.hostname, port: urlObj.port || 443, path: urlObj.pathname, method: 'GET', headers: { 'Authorization': `Bearer ${cfg.apiKey}` } }, (rp) => { rp.resume(); resolve(rp.statusCode < 500); }); rq.setTimeout(5000, () => { rq.destroy(); resolve(false); }); rq.on('error', () => resolve(false)); rq.end(); }); lastProbe = { ok, time: now }; return ok; } /** GET /v1/models — returns the configured TTS model. */ async function handleModels(req, res, requestId) { const cfg = loadConfig(); 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: cfg.model, object: 'model', created: 1710000000, owned_by: 'google' } ] })); } /** POST /v1/audio/speech — proxy TTS to RouterAI. */ async function handleSpeech(req, res, body, requestId) { const cfg = loadConfig(); const start = Date.now(); stats.tts_total++; let speechBody = body.toString('utf8'); let clientModel = null; let clientVoice = null; try { const parsed = JSON.parse(speechBody); clientModel = parsed.model; clientVoice = parsed.voice; if (parsed.response_format && ['mp3', 'opus', 'wav', 'pcm'].includes(parsed.response_format)) { delete parsed.response_format; log('WARN', { requestId, endpoint: '/v1/audio/speech', message: 'Removed unsupported response_format from TTS request' }); } if (!parsed.model) parsed.model = cfg.model; if (!parsed.voice) parsed.voice = cfg.voice; speechBody = JSON.stringify(parsed); } catch (e) { // Pass through non-JSON bodies unchanged. } const bodyBuf = Buffer.from(speechBody, 'utf8'); const urlObj = new URL(`${cfg.baseUrl}/audio/speech`); const proxyReq = https.request({ hostname: urlObj.hostname, port: urlObj.port || 443, path: urlObj.pathname, method: 'POST', headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Content-Type': req.headers['content-type'] || 'application/json', 'Content-Length': bodyBuf.length, 'X-Request-ID': requestId } }, (proxyRes) => { res.writeHead(proxyRes.statusCode, proxyRes.headers); proxyRes.pipe(res); const latency = Date.now() - start; if (proxyRes.statusCode >= 400) { stats.errors_total++; log('ERROR', { requestId, endpoint: '/v1/audio/speech', model: clientModel || cfg.model, voice: clientVoice || cfg.voice, latency_ms: latency, status: proxyRes.statusCode, error: 'TTS upstream error' }); } else { log('INFO', { requestId, endpoint: '/v1/audio/speech', model: clientModel || cfg.model, voice: clientVoice || cfg.voice, latency_ms: latency, status: proxyRes.statusCode }); } }); proxyReq.setTimeout(UPSTREAM_TIMEOUT, () => proxyReq.destroy(new Error('Upstream timeout'))); proxyReq.on('error', (e) => { stats.errors_total++; log('ERROR', { requestId, endpoint: '/v1/audio/speech', error: e.message, latency_ms: Date.now() - start }); errorResponse(res, 502, requestId, 'Upstream TTS error'); }); proxyReq.write(bodyBuf); proxyReq.end(); } /** POST /v1/audio/transcriptions — STT via RouterAI or whisper.cpp fallback. */ async function handleTranscriptions(req, res, body, requestId) { const cfg = loadConfig(); const start = Date.now(); stats.stt_total++; 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, 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 { 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) { log('WARN', { requestId, endpoint: '/v1/audio/transcriptions', error: 'No audio data' }); errorResponse(res, 400, requestId, 'No audio data'); return; } let text = null; let usedRouterAI = false; try { const payload = JSON.stringify({ model, input_audio: { data: audioData.toString('base64'), format: audioFormat }, language: 'ru' }); const urlObj = new URL(`${cfg.baseUrl}/audio/transcriptions`); text = await new Promise((resolve, reject) => { const rq = https.request({ hostname: urlObj.hostname, port: urlObj.port || 443, path: urlObj.pathname, method: 'POST', headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload), 'X-Request-ID': requestId } }, (rp) => { const chunks = []; rp.on('data', (c) => chunks.push(c)); rp.on('end', () => { const data = Buffer.concat(chunks).toString('utf8'); try { const j = JSON.parse(data); if (j.text !== undefined) resolve(j.text); else reject(new Error('No text in response')); } catch (e) { reject(new Error('Bad JSON response')); } }); }); rq.setTimeout(UPSTREAM_TIMEOUT, () => rq.destroy(new Error('Upstream timeout'))); rq.on('error', reject); rq.write(payload); rq.end(); }); usedRouterAI = true; } catch (e) { log('WARN', { requestId, endpoint: '/v1/audio/transcriptions', error: `RouterAI STT failed: ${e.message}` }); } if (text === null) { const whisperOk = fs.existsSync(WHISPER_BIN) && fs.existsSync(WHISPER_MODEL); if (!whisperOk) { stats.errors_total++; log('ERROR', { requestId, endpoint: '/v1/audio/transcriptions', error: 'No STT backend available' }); errorResponse(res, 502, requestId, 'STT backend unavailable'); return; } const tmpFile = path.join(TMP_DIR, `audio_${Date.now()}.${audioFormat}`); try { fs.writeFileSync(tmpFile, audioData); 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) => console.error(`whisper.cpp stderr: ${d.toString().trim()}`)); proc.on('close', () => { const lines = output.trim().split('\n').filter((l) => l.trim()); resolve(lines.length ? lines[lines.length - 1].trim() : ''); }); proc.on('error', reject); }); model = 'whisper-cpp-tiny'; } catch (e) { stats.errors_total++; log('ERROR', { requestId, endpoint: '/v1/audio/transcriptions', error: e.message }); errorResponse(res, 500, requestId, 'Local STT failed'); return; } finally { try { fs.unlinkSync(tmpFile); } catch (e) {} } } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ text, model, object: 'text' })); log('INFO', { requestId, endpoint: '/v1/audio/transcriptions', model, latency_ms: Date.now() - start, text_preview: text.slice(0, 60), status: 200, provider: usedRouterAI ? 'routerai' : 'whisper.cpp' }); } /** GET /health — enhanced status with connectivity probe and stats. */ async function handleHealth(req, res, requestId) { const cfg = loadConfig(); const whisperOk = fs.existsSync(WHISPER_BIN) && fs.existsSync(WHISPER_MODEL); const routeraiOk = await probeRouterAI(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok', service: 'routerai-voice-proxy', version: '3.0.0', uptime_seconds: Math.floor((Date.now() - stats.startTime) / 1000), tts: { model: cfg.model, voice: cfg.voice, provider: 'routerai' }, stt: { routerai: routeraiOk, whisper_local: whisperOk }, stats: { tts_total: stats.tts_total, stt_total: stats.stt_total, errors_total: stats.errors_total } })); } /** Gracefully stop accepting requests and exit after draining or timeout. */ function gracefulShutdown(signal) { log('INFO', { message: `Received ${signal}, starting graceful shutdown`, activeRequests }); shuttingDown = true; server.close(() => { log('INFO', { message: 'Server closed gracefully' }); process.exit(0); }); setTimeout(() => { log('WARN', { message: 'Forced shutdown, active requests remaining', activeRequests }); process.exit(1); }, UPSTREAM_TIMEOUT).unref(); } const server = http.createServer(async (req, res) => { if (shuttingDown) { res.writeHead(503, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Service shutting down' })); return; } activeRequests++; res.once('close', () => { activeRequests--; }); const requestId = req.headers['x-request-id'] || crypto.randomUUID(); res.setHeader('X-Request-ID', requestId); 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 url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); const body = await collectBody(req); if (url.pathname === '/v1/models' && req.method === 'GET') { await handleModels(req, res, requestId); } else if (url.pathname === '/v1/audio/speech' && req.method === 'POST') { await handleSpeech(req, res, body, requestId); } else if (url.pathname === '/v1/audio/transcriptions' && req.method === 'POST') { await handleTranscriptions(req, res, body, requestId); } else if (url.pathname === '/health' && req.method === 'GET') { await handleHealth(req, res, requestId); } else if (url.pathname === '/' && req.method === 'GET') { res.writeHead(301, { Location: '/health' }); res.end(); } else { errorResponse(res, 404, requestId, 'Not found'); } } catch (e) { stats.errors_total++; log('ERROR', { requestId, error: e.message }); if (e.message === 'Request body too large') { errorResponse(res, 413, requestId, 'Request body exceeds 50MB limit'); } else { errorResponse(res, 500, requestId, 'Internal server error'); } } }); process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); process.on('SIGINT', () => gracefulShutdown('SIGINT')); server.listen(PORT, '127.0.0.1', () => { const cfg = loadConfig(); const whisperOk = fs.existsSync(WHISPER_BIN) && fs.existsSync(WHISPER_MODEL); log('INFO', { message: 'RouterAI Voice Proxy started', version: '3.0.0', port: PORT, tts_model: cfg.model, whisper_local: whisperOk }); console.log(`RouterAI Voice Proxy v3 — http://127.0.0.1:${PORT}`); });