- Binary-safe multipart parser (Buffer-based) - Structured JSON logging with daily rotation (7-day, 10MB) - Config hot-reload (5s TTL, mtime-based) - Request tracing: X-Request-ID, crypto.randomUUID() - Enhanced /health: uptime, voice config, stats, RouterAI probe - Graceful shutdown: SIGTERM/SIGINT with drain - Timeout 30s on all https.request calls - Body size limit 50MB - Replace url.parse() with WHATWG new URL() - Sanitized error responses - Modular handler architecture Reviewed by: kimi-k2.7-code, qwen3.6-plus, deepseek-v4-flash Spec by: Roko (architect) Rewrite by: kimi-k2.7-code via OpenCode
536 lines
18 KiB
JavaScript
536 lines
18 KiB
JavaScript
#!/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}`);
|
|
});
|