Test
This commit is contained in:
@@ -9,6 +9,7 @@ let _raspberryPort = Number(process.env.RASPBERRY_PI_PORT) || 8000;
|
||||
let _token = process.env.QUIBOT_TOKEN ?? 'MY_SECRET_TOKEN';
|
||||
const APP_PORT = Number(process.env.PORT) || 5000;
|
||||
const piperUrl = process.env.PIPER_URL ?? '';
|
||||
const mcpUrl = process.env.MCP_URL ?? '';
|
||||
const llamacppUrl = process.env.LLAMA_CPP_URL ?? '';
|
||||
const llamacppApiKey = process.env.LLAMA_API_KEY ?? '';
|
||||
const llamaPreambleRaw = process.env.LLAMA_PREAMBLE ?? '';
|
||||
@@ -45,8 +46,11 @@ export const getLlamacppApiKey = () => llamacppApiKey;
|
||||
export const getLlamacppPreamble = () => llamacppPreamble;
|
||||
|
||||
export const getPiperUrl = () => piperUrl;
|
||||
export const getPiperModelDir = () =>
|
||||
process.env.PIPER_MODELS_DIR || join('/tmp', 'quibot-piper-models');
|
||||
export const getPiperModel = () =>
|
||||
process.env.PIPER_MODEL ||
|
||||
join('/tmp', 'quibot-piper-models', 'ca_ES-upc_ona-medium.onnx');
|
||||
join(getPiperModelDir(), 'ca_ES-upc_ona-medium.onnx');
|
||||
export const getMcpUrl = () => mcpUrl;
|
||||
|
||||
export const getAppPort = () => APP_PORT;
|
||||
|
||||
@@ -4,6 +4,7 @@ import router from './routes/router.js';
|
||||
import { getAppPort, getConfig } from './config.js';
|
||||
import { whisperService } from './services/whisper.service.js';
|
||||
import { piperService as piperWorker } from './services/piper.service.js';
|
||||
import { mcpClient } from './services/mcpClient.service.js';
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -25,16 +26,22 @@ app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', settings });
|
||||
});
|
||||
|
||||
const server = app.listen(getAppPort(), () => {
|
||||
const server = app.listen(getAppPort(), async () => {
|
||||
console.log(`QuiBot backend listening on port ${getAppPort()}`);
|
||||
whisperService.spawn();
|
||||
piperWorker.initWav().catch(() => { /* model may not exist yet → lazy init on first TTS call */ });
|
||||
try {
|
||||
await mcpClient.start();
|
||||
console.log('[server] MCP client started');
|
||||
} catch (err) {
|
||||
console.error(`[server] MCP client failed to start: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
});
|
||||
|
||||
async function shutdown(signal: string) {
|
||||
console.log(`[server] ${signal} received, shutting down...`);
|
||||
server.close(async () => {
|
||||
await Promise.all([whisperService.shutdown(), piperWorker.shutdown()]);
|
||||
await Promise.all([whisperService.shutdown(), piperWorker.shutdown(), mcpClient.shutdown()]);
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Persistent Piper TTS worker – single subprocess, model loaded once."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import wave
|
||||
@@ -32,8 +33,9 @@ def main():
|
||||
voice = None
|
||||
|
||||
# Defaults (will be overridden by init message)
|
||||
DEFAULT_MODEL = "/tmp/quibot-piper-models/ca_ES-upc_ona-medium.onnx"
|
||||
DEFAULT_CONFIG = "/tmp/quibot-piper-models/ca_ES-upc_ona-medium.onnx.json"
|
||||
_default_dir = os.environ.get("PIPER_MODELS_DIR", "/tmp/quibot-piper-models")
|
||||
DEFAULT_MODEL = os.path.join(_default_dir, "ca_ES-upc_ona-medium.onnx")
|
||||
DEFAULT_CONFIG = os.path.join(_default_dir, "ca_ES-upc_ona-medium.onnx.json")
|
||||
|
||||
# Signal node that the process is alive and listening
|
||||
print(json.dumps({"type": "ready"}), flush=True)
|
||||
@@ -62,6 +64,10 @@ def main():
|
||||
elif msg.get("type") == "synthesize":
|
||||
text = msg.get("text", "")
|
||||
msg_id = msg.get("msgId", "")
|
||||
|
||||
# NEW: output file path from message
|
||||
out_path = msg.get("outPath")
|
||||
|
||||
if not voice:
|
||||
print(json.dumps({
|
||||
"type": "error",
|
||||
@@ -70,25 +76,38 @@ def main():
|
||||
}), flush=True)
|
||||
continue
|
||||
|
||||
if not out_path:
|
||||
print(json.dumps({
|
||||
"type": "error",
|
||||
"text": "Missing outPath",
|
||||
"msgId": msg_id,
|
||||
}), flush=True)
|
||||
continue
|
||||
|
||||
try:
|
||||
import io
|
||||
import wave
|
||||
|
||||
buf = io.BytesIO()
|
||||
wf = wave.open(buf, 'wb')
|
||||
# set_wav_config is called inside synthesize_wav via set_wav_format=True (default)
|
||||
|
||||
voice.synthesize_wav(text, wf)
|
||||
wf.close()
|
||||
|
||||
wav_bytes = buf.getvalue()
|
||||
|
||||
# Frame: send length prefix as ASCII number + newline, then raw bytes
|
||||
header = f"{len(wav_bytes)}\n".encode('ascii')
|
||||
sys.stdout.buffer.write(header)
|
||||
sys.stdout.buffer.write(wav_bytes)
|
||||
sys.stdout.buffer.flush()
|
||||
# Write to file instead of stdout
|
||||
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(wav_bytes)
|
||||
|
||||
print(json.dumps({
|
||||
"type": "synthesized",
|
||||
"bytes": len(wav_bytes),
|
||||
"msgId": msg_id,
|
||||
"outPath": out_path
|
||||
}), flush=True)
|
||||
|
||||
except Exception as exc:
|
||||
err_msg = str(exc).replace('"', '\\"')
|
||||
print(json.dumps({
|
||||
@@ -97,6 +116,5 @@ def main():
|
||||
"msgId": msg_id,
|
||||
}), flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
74
backend/src/services/mcp.http.service.ts
Normal file
74
backend/src/services/mcp.http.service.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { getMcpUrl } from '../config';
|
||||
|
||||
class McpHttpService {
|
||||
private sessionId: string | null = null;
|
||||
|
||||
async callTool(name: string, args: Record<string, unknown>): Promise<{ text: string; isError?: boolean }> {
|
||||
const baseUrl = getMcpUrl();
|
||||
if (!baseUrl) {
|
||||
throw new Error('MCP HTTP service not configured (set MCP_URL env var)');
|
||||
}
|
||||
|
||||
const url = `${baseUrl}/mcp`;
|
||||
|
||||
if (!this.sessionId) {
|
||||
const initRes = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2025-03-26',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'quibot-backend', version: '1.0.0' },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const initData = await initRes.json();
|
||||
this.sessionId = String(initData.sessionId || initData.result?.sessionId);
|
||||
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'notifications/initialized',
|
||||
}),
|
||||
...(this.sessionId && { headers: { 'Mcp-SessionId': this.sessionId } }),
|
||||
});
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(this.sessionId && { 'Mcp-SessionId': this.sessionId }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: Date.now(),
|
||||
method: 'tools/call',
|
||||
params: { name, arguments: args },
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
return { text: JSON.stringify(data.error), isError: true };
|
||||
}
|
||||
const content = data.result?.content?.[0];
|
||||
if (!content?.text) {
|
||||
throw new Error('MCP tool returned no content');
|
||||
}
|
||||
return { text: content.text };
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.sessionId = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const mcpHttpService = new McpHttpService();
|
||||
202
backend/src/services/mcpClient.service.ts
Normal file
202
backend/src/services/mcpClient.service.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { getMcpUrl } from '../config';
|
||||
import { mcpHttpService } from './mcp.http.service';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = join(__filename, '..');
|
||||
|
||||
// Path to the compiled MCP server (two levels up from backend/src/)
|
||||
const MCP_BIN = join(__dirname, '..', '..', 'mcp', 'dist', 'index.js');
|
||||
|
||||
let _proc: ChildProcess | null = null;
|
||||
let nextId = 1;
|
||||
let pending = new Map<number | string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
|
||||
|
||||
function send(msg: Record<string, unknown>): number {
|
||||
const id = nextId++;
|
||||
_proc!.stdin!.write(JSON.stringify({ jsonrpc: '2.0', id, ...msg }) + '\n');
|
||||
return id;
|
||||
}
|
||||
|
||||
export const mcpClient = {
|
||||
async start(): Promise<void> {
|
||||
const hasMcpBin = (() => {
|
||||
try {
|
||||
require('fs').accessSync(MCP_BIN);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
if (!hasMcpBin) {
|
||||
const url = getMcpUrl();
|
||||
if (url) {
|
||||
console.log('[mcp] Local MCP binary not found, using HTTP service at', url);
|
||||
return;
|
||||
}
|
||||
throw new Error('MCP local binary and HTTP URL both unavailable');
|
||||
}
|
||||
|
||||
if (_proc) return;
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
_proc = spawn('node', [MCP_BIN], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env } });
|
||||
|
||||
_proc.stdout!.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString();
|
||||
for (const line of text.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
let parsed: { jsonrpc?: string; id?: number | string; method?: string; result?: unknown; error?: unknown };
|
||||
try { parsed = JSON.parse(line); } catch { continue; }
|
||||
if (parsed.jsonrpc !== '2.0') continue;
|
||||
if (parsed.method) {
|
||||
// notifications or responses without matching id — ignore for now
|
||||
continue;
|
||||
}
|
||||
if (!parsed.id) continue;
|
||||
const p = pending.get(parsed.id);
|
||||
if (!p) continue;
|
||||
pending.delete(parsed.id);
|
||||
if (parsed.error) {
|
||||
p.reject(new Error(`MCP error: ${JSON.stringify(parsed.error)}`));
|
||||
} else {
|
||||
p.resolve(parsed.result);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_proc.stderr!.on('data', (chunk: Buffer) => {
|
||||
console.log(`[mcp-client] stderr: ${chunk.toString().trim()}`);
|
||||
});
|
||||
|
||||
_proc.on('exit', (code, signal) => {
|
||||
console.error(`[mcp-client] Exited code=${code} signal=${signal}`);
|
||||
_proc = null;
|
||||
for (const [, p] of pending) {
|
||||
p.reject(new Error('MCP client process exited'));
|
||||
}
|
||||
pending.clear();
|
||||
});
|
||||
|
||||
_proc.on('error', (err: Error) => {
|
||||
console.error(`[mcp-client] Error: ${err.message}`);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// Send initialize request
|
||||
const initId = send({
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2025-03-26',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'quibot-backend', version: '1.0.0' },
|
||||
},
|
||||
});
|
||||
|
||||
pending.set(initId, {
|
||||
resolve: () => {
|
||||
// Send initialized notification
|
||||
_proc!.stdin!.write(
|
||||
JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n',
|
||||
);
|
||||
resolve();
|
||||
},
|
||||
reject,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
const p = pending.get(initId);
|
||||
if (p) {
|
||||
pending.delete(initId);
|
||||
p.reject(new Error('MCP initialize timed out'));
|
||||
}
|
||||
}, 15_000);
|
||||
});
|
||||
},
|
||||
|
||||
async callTool(name: string, args: Record<string, unknown>): Promise<{ text: string; isError?: boolean }> {
|
||||
if (!_proc) {
|
||||
await this.start();
|
||||
}
|
||||
|
||||
try {
|
||||
return await this._callToolLocal(name, args);
|
||||
} catch (localErr) {
|
||||
const url = getMcpUrl();
|
||||
if (url) {
|
||||
console.log(`[mcp] Local MCP failed: ${localErr instanceof Error ? localErr.message : localErr}. Falling back to HTTP service.`);
|
||||
return await mcpHttpService.callTool(name, args);
|
||||
}
|
||||
throw localErr;
|
||||
}
|
||||
},
|
||||
|
||||
async _callToolLocal(name: string, args: Record<string, unknown>): Promise<{ text: string; isError?: boolean }> {
|
||||
if (!_proc?.stdin) {
|
||||
throw new Error('MCP client not ready');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = send({
|
||||
method: 'tools/call',
|
||||
params: { name, arguments: args },
|
||||
});
|
||||
|
||||
let cleared = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (cleared) return;
|
||||
cleared = true;
|
||||
pending.delete(id);
|
||||
reject(new Error(`MCP tool "${name}" timed out`));
|
||||
}, 180_000);
|
||||
|
||||
pending.set(id, {
|
||||
resolve: (result: unknown) => {
|
||||
if (cleared) return;
|
||||
cleared = true;
|
||||
clearTimeout(timer);
|
||||
pending.delete(id);
|
||||
const res = result as { content?: Array<{ type: string; text?: string }> };
|
||||
if (res?.content?.[0]?.text !== undefined) {
|
||||
resolve({ text: res.content[0].text });
|
||||
} else {
|
||||
reject(new Error('MCP tool returned no content'));
|
||||
}
|
||||
},
|
||||
reject: (err: Error) => {
|
||||
if (cleared) return;
|
||||
cleared = true;
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
if (!_proc) return;
|
||||
_proc.kill('SIGTERM');
|
||||
const current = _proc;
|
||||
await new Promise<void>((resolve) => {
|
||||
let done = false;
|
||||
const cleanup = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
_proc = null;
|
||||
for (const [, p] of pending) {
|
||||
p.reject(new Error('MCP client shut down'));
|
||||
}
|
||||
pending.clear();
|
||||
resolve();
|
||||
};
|
||||
current.once('exit', () => cleanup());
|
||||
setTimeout(() => {
|
||||
const proc = _proc;
|
||||
if (proc && !proc.killed) proc.kill('SIGKILL');
|
||||
cleanup();
|
||||
}, 3000);
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -18,7 +18,7 @@ type PiperMsg =
|
||||
| { type: 'ready' }
|
||||
| { type: 'init_ok' }
|
||||
| { type: 'init_error'; error: string }
|
||||
| { type: 'synthesized'; wavPath: string; bytes?: number; msgId: string }
|
||||
| { type: 'synthesized'; outPath: string; bytes?: number; msgId: string }
|
||||
| { type: 'error'; text: string; msgId: string };
|
||||
|
||||
class PiperLocalService {
|
||||
@@ -45,41 +45,95 @@ class PiperLocalService {
|
||||
}
|
||||
|
||||
private handleLine(line: string): void {
|
||||
try { const msg = JSON.parse(line) as PiperMsg; /* handled below */ } catch { return; }
|
||||
const msg = JSON.parse(line) as PiperMsg;
|
||||
if (msg.type === 'ready') return;
|
||||
if (msg.type === 'init_ok') { this.resolveInit(); }
|
||||
if (msg.type === 'init_error') {
|
||||
if (this.pendingInit) { this.pendingInit = null; this.initResolve = null; }
|
||||
this.initReject(new Error(msg.error));
|
||||
}
|
||||
if (msg.type === 'synthesized') {
|
||||
this.resolveResponse(msg.msgId, msg.wavPath);
|
||||
}
|
||||
console.log('[RX]', line);
|
||||
let msg: PiperMsg;
|
||||
|
||||
try {
|
||||
msg = JSON.parse(line) as PiperMsg;
|
||||
} catch {
|
||||
console.warn('[piper-svc] Invalid JSON:', line);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (msg.type) {
|
||||
case 'ready':
|
||||
break;
|
||||
|
||||
case 'init_ok':
|
||||
this.resolveInit();
|
||||
break;
|
||||
|
||||
case 'init_error':
|
||||
this.resolveInitError(new Error(msg.error));
|
||||
break;
|
||||
|
||||
case 'synthesized':
|
||||
this.resolveResponse(msg.msgId, msg.outPath);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
this.rejectResponse(
|
||||
msg.msgId,
|
||||
new Error(msg.text)
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async writeStdin(line: string): Promise<void> {
|
||||
if (!this.proc?.stdin) throw new Error('piper-svc: stdin unavailable');
|
||||
this.proc.stdin.write(line + '\n');
|
||||
if (!this.proc?.stdin) {
|
||||
throw new Error('piper-svc: stdin unavailable');
|
||||
}
|
||||
|
||||
// ── pending-init promises (simplest possible design) ──
|
||||
console.log('[TX]', line);
|
||||
|
||||
this.proc.stdin.write(line + '\n');
|
||||
}
|
||||
|
||||
// ── pending-init promises (separate from synth to avoid clearing respMap on init failure) ──
|
||||
private initResolve: (() => void) | null = null;
|
||||
private initReject: (e: Error) => void = () => { throw new Error('no reject handler'); };
|
||||
private initReject: ((e: Error) => void) | null = null;
|
||||
|
||||
private resolveInit(): void {
|
||||
if (!this.pendingInit) return;
|
||||
this.initResolve?.(); this.pendingInit = null;
|
||||
this.initResolve = null;
|
||||
}
|
||||
if (!this.pendingInit) return;
|
||||
|
||||
// ── pending synth responses ──
|
||||
private respMap = new Map<string, (wavPath: string) => void>();
|
||||
this.initResolve?.();
|
||||
|
||||
this.pendingInit = null;
|
||||
this.initResolve = null;
|
||||
this.initReject = null;
|
||||
}
|
||||
|
||||
private rejectResponse(msgId: string, err: Error): void {
|
||||
const entry = this.respMap.get(msgId);
|
||||
|
||||
this.respMap.delete(msgId);
|
||||
|
||||
if (entry) {
|
||||
entry.reject(err);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveInitError(err: Error): void {
|
||||
if (!this.pendingInit) return;
|
||||
|
||||
this.initReject?.(err);
|
||||
|
||||
this.pendingInit = null;
|
||||
this.initResolve = null;
|
||||
this.initReject = null;
|
||||
}
|
||||
|
||||
// ── pending synth responses (separate from init so init failure doesn't clear them) ──
|
||||
private respMap = new Map<string, {
|
||||
resolve: (wavPath: string) => void;
|
||||
reject: (e: Error) => void;
|
||||
}>();
|
||||
|
||||
private resolveResponse(msgId: string, wavPath: string): void {
|
||||
const fn = this.respMap.get(msgId);
|
||||
const entry = this.respMap.get(msgId);
|
||||
this.respMap.delete(msgId);
|
||||
if (fn) fn(wavPath);
|
||||
if (entry?.resolve) entry.resolve(wavPath);
|
||||
}
|
||||
|
||||
// ── public spawn / initWav / synthWav ──
|
||||
@@ -87,21 +141,31 @@ class PiperLocalService {
|
||||
private async _spawn(): Promise<void> {
|
||||
if (this.proc) return;
|
||||
|
||||
const workerPath = join(SCRIPT_DIR, 'src', 'piper-worker.py');
|
||||
const venv = process.env.VIRTUAL_ENV || join(SCRIPT_DIR, '.venv', 'bin', 'python3');
|
||||
const workerPath = join(SCRIPT_DIR, 'piper-worker.py');
|
||||
const venv = process.env.VIRTUAL_ENV || join(SCRIPT_DIR, '..', '.venv', 'bin', 'python3');
|
||||
this.proc = spawn(venv, [workerPath], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
|
||||
this.setupStdout();
|
||||
|
||||
if (this.proc.stderr) {
|
||||
this.proc.stderr.on('data', (chunk: Buffer) => {
|
||||
console.error(
|
||||
'[piper-worker]',
|
||||
chunk.toString().trim()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
this.proc.on('exit', () => {
|
||||
// reject all pending
|
||||
for (const [, fn] of this.respMap) fn('');
|
||||
console.log('[piper-svc] Process exited, rejecting all pending synths');
|
||||
// reject all pending (new format: {resolve, reject})
|
||||
for (const [, entry] of this.respMap) entry.reject(new Error('piper process exited'));
|
||||
this.respMap.clear();
|
||||
if (this.pendingInit) { this.initReject(new Error('piper process exited')); this.pendingInit = null; }
|
||||
});
|
||||
|
||||
// ── cleanup old WAV files every 5 min ──
|
||||
const timer = setInterval(() => {
|
||||
this.cleanupTimer = setInterval(() => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
for (const entry of readdirSync(TTS_DIR)) {
|
||||
@@ -119,18 +183,10 @@ class PiperLocalService {
|
||||
if (!this.proc) await this._spawn();
|
||||
if (this.pendingInit) return this.pendingInit;
|
||||
|
||||
this.pendingInit = new Promise<void>((resolve, reject) => {
|
||||
// override the global reject
|
||||
const origReject = this.initReject;
|
||||
this.pendingInit = new Promise<void>((resolve, reject) => {
|
||||
this.initResolve = resolve;
|
||||
this.initReject = (e: Error) => {
|
||||
// cleanup
|
||||
for (const [, fn] of this.respMap) fn('');
|
||||
this.respMap.clear();
|
||||
reject(e);
|
||||
};
|
||||
this.initReject = reject;
|
||||
});
|
||||
|
||||
const modelPath = getPiperModel() || join('/tmp', 'quibot-piper-models', 'ca_ES-upc_ona-medium.onnx');
|
||||
const cfgPath = modelPath.replace(/\.onnx$/, '.onnx.json');
|
||||
await this.writeStdin(
|
||||
@@ -155,8 +211,10 @@ class PiperLocalService {
|
||||
}
|
||||
|
||||
async synthWav(text: string): Promise<Buffer> {
|
||||
await this.initWav();
|
||||
if (!this.proc) await this._spawn(); // auto-spawn; init runs concurrently
|
||||
const msgId = randomUUID() + '-' + Date.now();
|
||||
const outPath = join(TTS_DIR, `${msgId}.wav`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let cleared = false;
|
||||
@@ -164,24 +222,35 @@ class PiperLocalService {
|
||||
if (cleared) return;
|
||||
cleared = true;
|
||||
this.respMap.delete(msgId);
|
||||
reject(new Error('piper-svc: synthesis timed out'));
|
||||
}, 30_000);
|
||||
reject(new Error('piper-svc: synthesis timed out after 120s'));
|
||||
}, 120_000);
|
||||
|
||||
this.respMap.set(msgId, (wavPath: string) => {
|
||||
if (cleared) return;
|
||||
cleared = true;
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
const buf = readFileSync(wavPath);
|
||||
rmSync(wavPath, { force: true });
|
||||
resolve(buf);
|
||||
} catch (err: unknown) {
|
||||
reject(err instanceof Error ? err : new Error('read WAV failed'));
|
||||
}
|
||||
this.respMap.set(msgId, {
|
||||
resolve: (wavPath: string) => {
|
||||
if (cleared) return;
|
||||
cleared = true;
|
||||
clearTimeout(timer);
|
||||
console.log(`[piper-svc] Synthesized ${wavPath} (${Date.now()})`);
|
||||
try {
|
||||
const buf = readFileSync(wavPath);
|
||||
rmSync(wavPath, { force: true });
|
||||
resolve(buf);
|
||||
} catch (err: unknown) {
|
||||
reject(err instanceof Error ? err : new Error('read WAV failed'));
|
||||
}
|
||||
},
|
||||
reject: (e: Error) => {
|
||||
if (cleared) return;
|
||||
cleared = true;
|
||||
clearTimeout(timer);
|
||||
this.respMap.delete(msgId);
|
||||
reject(e);
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[piper-svc] synthesize ${text.substring(0, 40)}... (msgId=${msgId})`);
|
||||
this.writeStdin(
|
||||
JSON.stringify({ type: 'synthesize', text, msgId }),
|
||||
JSON.stringify({ type: 'synthesize', text, msgId, outPath }),
|
||||
).catch((e) => {
|
||||
if (cleared) return;
|
||||
cleared = true;
|
||||
@@ -194,7 +263,10 @@ class PiperLocalService {
|
||||
// ── shutdown ──
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.cleanupTimer?.refresh(); this.cleanupTimer = null;
|
||||
if (this.cleanupTimer) {
|
||||
clearInterval(this.cleanupTimer);
|
||||
this.cleanupTimer = null;
|
||||
}
|
||||
if (!this.proc) return;
|
||||
const p = this.proc; this.proc = null;
|
||||
await new Promise<void>((res) => {
|
||||
|
||||
@@ -8,7 +8,7 @@ const __dirname = join(__filename, '..');
|
||||
|
||||
const SCRIPT_DIR = join(__dirname, '..');
|
||||
|
||||
const PYTHON = join(SCRIPT_DIR, '../.venv/bin/python3');
|
||||
const PYTHON = join(SCRIPT_DIR, '..', '.venv', 'bin', 'python3');
|
||||
|
||||
const whisperModel = process.env.WHISPER_MODEL ?? 'base';
|
||||
const whisperLanguage = process.env.WHISPER_LANGUAGE ?? 'ca';
|
||||
@@ -153,7 +153,7 @@ class WhisperService {
|
||||
this.spawn();
|
||||
}
|
||||
|
||||
await this.waitForInit();
|
||||
// await this.waitForInit();
|
||||
|
||||
const msgId = randomUUID() + '-' + Date.now();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user