@danieltmn/tamnora 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +83 -0
- package/harness/packages/cli/src/cli.js +1 -1
- package/harness/packages/cli/src/commands/bridge.js +180 -119
- package/harness/packages/cli/src/commands/server.js +26 -50
- package/harness/packages/cli/src/lib/daemon.js +92 -40
- package/harness/packages/cli/test/release.test.js +9 -0
- package/harness/packages/core/src/agent/modes.js +1 -1
- package/harness/packages/core/src/index.js +14 -1
- package/harness/packages/core/src/open.js +43 -0
- package/harness/packages/core/src/pid.js +35 -0
- package/harness/packages/core/src/tools/executors.js +16 -0
- package/harness/packages/core/src/tools/labels.js +2 -0
- package/harness/packages/core/src/tools/permissions.js +1 -0
- package/harness/packages/core/src/tools/schema.js +17 -0
- package/harness/packages/core/src/tts/edge.js +232 -0
- package/harness/packages/core/src/tunnel.js +180 -19
- package/harness/packages/core/test/open.test.js +19 -0
- package/harness/packages/core/test/tools.test.js +12 -0
- package/harness/packages/core/test/tts.test.js +41 -0
- package/harness/packages/server/public/app.js +4715 -3975
- package/harness/packages/server/public/index.html +63 -5
- package/harness/packages/server/public/reconcile.js +121 -0
- package/harness/packages/server/public/relayClient.js +170 -0
- package/harness/packages/server/public/runStream.js +207 -0
- package/harness/packages/server/public/styles.css +142 -5
- package/harness/packages/server/src/auth.js +29 -5
- package/harness/packages/server/src/lib/agentRun.js +77 -0
- package/harness/packages/server/src/lib/announcer.js +92 -0
- package/harness/packages/server/src/lib/approvals.js +25 -3
- package/harness/packages/server/src/lib/channels/telegram.js +85 -20
- package/harness/packages/server/src/lib/ffmpeg.js +48 -0
- package/harness/packages/server/src/lib/procs.js +3 -18
- package/harness/packages/server/src/lib/runManager.js +24 -2
- package/harness/packages/server/src/lib/sessionView.js +43 -0
- package/harness/packages/server/src/lib/tasks.js +4 -9
- package/harness/packages/server/src/lib/ttsSettings.js +42 -0
- package/harness/packages/server/src/lib/workspace.js +37 -1
- package/harness/packages/server/src/routes/chat.js +48 -47
- package/harness/packages/server/src/routes/events.js +35 -0
- package/harness/packages/server/src/routes/files.js +22 -26
- package/harness/packages/server/src/routes/folders.js +9 -17
- package/harness/packages/server/src/routes/git.js +6 -14
- package/harness/packages/server/src/routes/procs.js +14 -16
- package/harness/packages/server/src/routes/sessions.js +12 -19
- package/harness/packages/server/src/routes/shell.js +5 -9
- package/harness/packages/server/src/routes/state.js +32 -0
- package/harness/packages/server/src/routes/tts.js +82 -0
- package/harness/packages/server/src/server.js +21 -1
- package/harness/packages/server/test/announcer.test.js +51 -0
- package/harness/packages/server/test/reconcile.test.js +146 -0
- package/harness/packages/server/test/relay.test.js +219 -0
- package/harness/packages/server/test/runStream.test.js +245 -0
- package/harness/packages/server/test/security.test.js +252 -0
- package/harness/packages/server/test/server.test.js +511 -1
- package/harness/packages/server/test/telegram-voz.test.js +156 -0
- package/harness/packages/server/test/tts.test.js +133 -0
- package/package.json +1 -1
|
@@ -1,69 +1,121 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
-
import { readFileSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { appendFileSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
3
4
|
import {
|
|
5
|
+
setHome,
|
|
6
|
+
ensureHome,
|
|
4
7
|
runtimePath,
|
|
5
8
|
pidPath,
|
|
6
|
-
|
|
9
|
+
bridgeStatePath,
|
|
7
10
|
serverLogPath,
|
|
11
|
+
bridgeLogPath,
|
|
12
|
+
killPidTree,
|
|
13
|
+
pidAlive,
|
|
8
14
|
} from '../../../core/src/index.js';
|
|
9
15
|
|
|
10
16
|
/** Mata un proceso y su arbol de hijos (taskkill en Windows; grupo en POSIX). */
|
|
11
17
|
export function killPid(pid) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
killPidTree(pid);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export { pidAlive };
|
|
22
|
+
|
|
23
|
+
/** Crea `setHome` + env TAMNORA_HOME a partir de `--home` (comun a CLI commands). */
|
|
24
|
+
export function applyHomeOverride(values) {
|
|
25
|
+
if (!values?.home) return;
|
|
26
|
+
setHome(values.home);
|
|
27
|
+
process.env.TAMNORA_HOME = path.resolve(values.home);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** read/write/clear de un archivo JSON de estado (runtime del server, bridge). */
|
|
31
|
+
function jsonStateIO(file, { extraClear = [] } = {}) {
|
|
32
|
+
return {
|
|
33
|
+
read() {
|
|
17
34
|
try {
|
|
18
|
-
|
|
35
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
19
36
|
} catch {
|
|
20
|
-
|
|
37
|
+
return null;
|
|
21
38
|
}
|
|
39
|
+
},
|
|
40
|
+
write(data) {
|
|
41
|
+
ensureHome();
|
|
22
42
|
try {
|
|
23
|
-
|
|
43
|
+
writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
|
|
24
44
|
} catch {
|
|
25
|
-
/*
|
|
45
|
+
/* sin estado no detenemos nada */
|
|
26
46
|
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
47
|
+
},
|
|
48
|
+
clear() {
|
|
49
|
+
for (const f of [file, ...extraClear]) {
|
|
50
|
+
try {
|
|
51
|
+
unlinkSync(f);
|
|
52
|
+
} catch {
|
|
53
|
+
/* nada */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
};
|
|
31
58
|
}
|
|
32
59
|
|
|
33
|
-
export
|
|
34
|
-
|
|
35
|
-
try {
|
|
36
|
-
process.kill(pid, 0);
|
|
37
|
-
return true;
|
|
38
|
-
} catch {
|
|
39
|
-
return false;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
60
|
+
export const runtimeState = jsonStateIO(runtimePath(), { extraClear: [pidPath()] });
|
|
61
|
+
export const bridgeState = jsonStateIO(bridgeStatePath());
|
|
42
62
|
|
|
43
63
|
export function readRuntime() {
|
|
44
|
-
|
|
45
|
-
return JSON.parse(readFileSync(runtimePath(), 'utf8'));
|
|
46
|
-
} catch {
|
|
47
|
-
return null;
|
|
48
|
-
}
|
|
64
|
+
return runtimeState.read();
|
|
49
65
|
}
|
|
50
|
-
|
|
51
66
|
export function writeRuntime(data) {
|
|
52
|
-
|
|
53
|
-
writeFileSync(runtimePath(), `${JSON.stringify(data, null, 2)}\n`);
|
|
54
|
-
} catch {
|
|
55
|
-
/* sin runtime no detenemos nada */
|
|
56
|
-
}
|
|
67
|
+
runtimeState.write(data);
|
|
57
68
|
}
|
|
58
|
-
|
|
59
69
|
export function clearRuntime() {
|
|
60
|
-
|
|
70
|
+
runtimeState.clear();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Logger con timestamp a archivo + consola (server, bridge, tunnel). */
|
|
74
|
+
export function createFileLogger(logPath) {
|
|
75
|
+
return (line) => {
|
|
76
|
+
const text = `[${new Date().toISOString()}] ${line}`;
|
|
61
77
|
try {
|
|
62
|
-
|
|
78
|
+
ensureHome();
|
|
79
|
+
appendFileSync(logPath, `${text}\n`);
|
|
63
80
|
} catch {
|
|
64
|
-
/* nada */
|
|
81
|
+
/* sin log no detenemos nada */
|
|
65
82
|
}
|
|
66
|
-
|
|
83
|
+
console.log(text);
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Hijo detached con un flag de env que marca que es el proceso child. */
|
|
88
|
+
export function spawnDetached(args, { envFlag, env = process.env } = {}) {
|
|
89
|
+
const child = spawn(process.execPath, args, {
|
|
90
|
+
detached: true,
|
|
91
|
+
stdio: 'ignore',
|
|
92
|
+
windowsHide: true,
|
|
93
|
+
env: envFlag ? { ...env, [envFlag]: '1' } : { ...env },
|
|
94
|
+
});
|
|
95
|
+
child.unref();
|
|
96
|
+
let dead = false;
|
|
97
|
+
child.on('exit', () => {
|
|
98
|
+
dead = true;
|
|
99
|
+
});
|
|
100
|
+
return { child, isDead: () => dead };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Espera a que `readFn()` devuelva un estado cuyo pid este vivo (y sea el
|
|
105
|
+
* child esperado, si hay). Null si el child murio o vencio el timeout.
|
|
106
|
+
*/
|
|
107
|
+
export function waitForState(readFn, { childPid, isDead = () => false, timeoutMs = 30000, tickMs = 400 } = {}) {
|
|
108
|
+
return new Promise((resolve) => {
|
|
109
|
+
const start = Date.now();
|
|
110
|
+
const tick = () => {
|
|
111
|
+
if (isDead()) return resolve(null);
|
|
112
|
+
const state = readFn();
|
|
113
|
+
if (state?.pid && pidAlive(state.pid) && (!childPid || state.pid === childPid)) return resolve(state);
|
|
114
|
+
if (Date.now() - start > timeoutMs) return resolve(null);
|
|
115
|
+
setTimeout(tick, tickMs);
|
|
116
|
+
};
|
|
117
|
+
tick();
|
|
118
|
+
});
|
|
67
119
|
}
|
|
68
120
|
|
|
69
121
|
/** PIDs escuchando en un puerto TCP (netstat en Windows; lsof en el resto). */
|
|
@@ -32,3 +32,12 @@ test('renderChangelog promotes the Unreleased section', () => {
|
|
|
32
32
|
assert.doesNotMatch(out, /## \[Unreleased\]/);
|
|
33
33
|
assert.match(out, /## \[1\.0\.0\]/);
|
|
34
34
|
});
|
|
35
|
+
|
|
36
|
+
test('renderChangelog promotes the [No publicado] section', () => {
|
|
37
|
+
const cl = '# Changelog\n\n## [No publicado]\n\n### Agregado\n\n- algo\n\n## [1.0.0] - 2026-01-01\n\n- viejo\n';
|
|
38
|
+
const out = renderChangelog(cl, '1.1.0', '- nota', '2026-02-02');
|
|
39
|
+
assert.match(out, /## \[1\.1\.0\] - 2026-02-02/);
|
|
40
|
+
assert.match(out, /- algo/);
|
|
41
|
+
assert.doesNotMatch(out, /## \[No publicado\]/);
|
|
42
|
+
assert.match(out, /## \[1\.0\.0\]/);
|
|
43
|
+
});
|
|
@@ -13,7 +13,7 @@ export const MODES = {
|
|
|
13
13
|
id: 'plan',
|
|
14
14
|
label: 'Plan',
|
|
15
15
|
description: 'Solo lectura: investiga y propone un plan, sin modificar nada.',
|
|
16
|
-
tools: ['read', 'list', 'glob', 'grep', 'fetch_url', 'git_status', 'git_diff', 'git_log'],
|
|
16
|
+
tools: ['read', 'list', 'glob', 'grep', 'fetch_url', 'speak', 'git_status', 'git_diff', 'git_log'],
|
|
17
17
|
systemPrompt:
|
|
18
18
|
'Estás en modo PLAN: no modifiques archivos ni ejecutes comandos. Investigá el proyecto con las herramientas de solo lectura y respondé con un plan concreto y accionable.',
|
|
19
19
|
},
|
|
@@ -93,7 +93,20 @@ export {
|
|
|
93
93
|
registryServerToConfig,
|
|
94
94
|
normalizeMcpConfig,
|
|
95
95
|
} from './mcp/registry.js';
|
|
96
|
-
export { parseTunnelmoleUrls, startTunnelmole, startTunnel } from './tunnel.js';
|
|
96
|
+
export { parseTunnelmoleUrls, startTunnelmole, startRelay, startTunnel, tunnelState, tunnelActive } from './tunnel.js';
|
|
97
|
+
export {
|
|
98
|
+
TRUSTED_CLIENT_TOKEN,
|
|
99
|
+
EDGE_VERSION,
|
|
100
|
+
FORMATO_POR_DEFECTO,
|
|
101
|
+
MAX_TEXTO,
|
|
102
|
+
VOZ_POR_DEFECTO,
|
|
103
|
+
generarSecMsGec,
|
|
104
|
+
generarSsml,
|
|
105
|
+
sintetizar,
|
|
106
|
+
listarVoces,
|
|
107
|
+
} from './tts/edge.js';
|
|
108
|
+
export { pidAlive, killPidTree } from './pid.js';
|
|
109
|
+
export { openBrowser, openerFor } from './open.js';
|
|
97
110
|
export { detectProcs, packageManager, DEFAULT_PROCESS_ALLOW, staticServerChip } from './procs/detect.js';
|
|
98
111
|
export { killPort } from './procs/kill.js';
|
|
99
112
|
export { readPackageVersion } from './version.js';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Abre una URL en el navegador predeterminado.
|
|
5
|
+
*
|
|
6
|
+
* Es best-effort y nunca falla el comando que la llama: si no hay navegador
|
|
7
|
+
* (contenedor, servicio, CI) solo se avisa y se sigue. Por eso devuelve una
|
|
8
|
+
* promesa que resuelve siempre.
|
|
9
|
+
*/
|
|
10
|
+
export function openBrowser(url, { platform = process.platform, log = console.warn } = {}) {
|
|
11
|
+
const [command, args] = openerFor(url, platform);
|
|
12
|
+
if (!command) {
|
|
13
|
+
log(`No se pudo abrir el navegador automaticamente: ${url}`);
|
|
14
|
+
return Promise.resolve(false);
|
|
15
|
+
}
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
try {
|
|
18
|
+
const child = spawn(command, args, {
|
|
19
|
+
// Sin shell: una URL con query (&, ?) no debe pasar por cmd.
|
|
20
|
+
shell: false,
|
|
21
|
+
detached: true,
|
|
22
|
+
stdio: 'ignore',
|
|
23
|
+
windowsHide: true,
|
|
24
|
+
});
|
|
25
|
+
child.once('error', (error) => {
|
|
26
|
+
log(`No se pudo abrir el navegador (${error.message}). Entra a ${url}`);
|
|
27
|
+
resolve(false);
|
|
28
|
+
});
|
|
29
|
+
child.unref();
|
|
30
|
+
resolve(true);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
log(`No se pudo abrir el navegador (${error.message}). Entra a ${url}`);
|
|
33
|
+
resolve(false);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** El comando nativo de cada plataforma; `rundll32` evita el shell en Windows. */
|
|
39
|
+
export function openerFor(url, platform = process.platform) {
|
|
40
|
+
if (platform === 'win32') return ['rundll32.exe', ['url.dll,FileProtocolHandler', url]];
|
|
41
|
+
if (platform === 'darwin') return ['open', [url]];
|
|
42
|
+
return ['xdg-open', [url]];
|
|
43
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
/** True si el proceso con ese pid esta vivo. */
|
|
4
|
+
export function pidAlive(pid) {
|
|
5
|
+
if (!pid) return false;
|
|
6
|
+
try {
|
|
7
|
+
process.kill(pid, 0);
|
|
8
|
+
return true;
|
|
9
|
+
} catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Mata un proceso y su arbol de hijos (taskkill /T en Windows; grupo + SIGTERM en POSIX). */
|
|
15
|
+
export function killPidTree(pid) {
|
|
16
|
+
if (!pid) return;
|
|
17
|
+
try {
|
|
18
|
+
if (process.platform === 'win32') {
|
|
19
|
+
spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true });
|
|
20
|
+
} else {
|
|
21
|
+
try {
|
|
22
|
+
process.kill(-pid, 'SIGTERM');
|
|
23
|
+
} catch {
|
|
24
|
+
/* sin grupo propio */
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
process.kill(pid, 'SIGTERM');
|
|
28
|
+
} catch {
|
|
29
|
+
/* ya no esta */
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
/* ya no esta */
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -404,6 +404,21 @@ export async function gitCommitTool(root, { message, files } = {}) {
|
|
|
404
404
|
return runGit(root, ['commit', '-m', message]);
|
|
405
405
|
}
|
|
406
406
|
|
|
407
|
+
const MAX_SPEAK_TEXT = 1200;
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Pedido de audio: valida el texto y lo devuelve. La sintesis y la reproduccion
|
|
411
|
+
* son del cliente (la consola lo reproduce y Telegram lo manda como nota de
|
|
412
|
+
* voz), asi que aca solo se normaliza el pedido y se acota el largo.
|
|
413
|
+
*/
|
|
414
|
+
export function speakTool(_root, { texto, voice, rate } = {}) {
|
|
415
|
+
const value = String(texto ?? '').replace(/\s+/g, ' ').trim();
|
|
416
|
+
if (!value) return 'Error: falta "texto".';
|
|
417
|
+
const recortado = value.length > MAX_SPEAK_TEXT ? `${value.slice(0, MAX_SPEAK_TEXT - 1).trimEnd()}…` : value;
|
|
418
|
+
const opciones = [voice, rate].filter(Boolean).join(', ');
|
|
419
|
+
return `Audio pedido${opciones ? ` (${opciones})` : ''}: "${recortado}". El cliente lo reproduce o lo envía como nota de voz.`;
|
|
420
|
+
}
|
|
421
|
+
|
|
407
422
|
export const EXECUTORS = {
|
|
408
423
|
read: readTool,
|
|
409
424
|
write: writeTool,
|
|
@@ -417,4 +432,5 @@ export const EXECUTORS = {
|
|
|
417
432
|
git_diff: gitDiffTool,
|
|
418
433
|
git_log: gitLogTool,
|
|
419
434
|
git_commit: gitCommitTool,
|
|
435
|
+
speak: speakTool,
|
|
420
436
|
};
|
|
@@ -50,6 +50,8 @@ export function summarizeToolCall(name, args = {}) {
|
|
|
50
50
|
}
|
|
51
51
|
case 'fetch_url':
|
|
52
52
|
return shorten(a.url, 90);
|
|
53
|
+
case 'speak':
|
|
54
|
+
return shorten(a.texto, 90);
|
|
53
55
|
case 'git_diff':
|
|
54
56
|
return [a.ref ? `vs ${shorten(a.ref, 30)}` : a.staged ? 'staged' : 'unstaged', a.path ? path(a.path) : '']
|
|
55
57
|
.filter(Boolean)
|
|
@@ -133,6 +133,23 @@ export const TOOL_DEFINITIONS = [
|
|
|
133
133
|
},
|
|
134
134
|
},
|
|
135
135
|
},
|
|
136
|
+
{
|
|
137
|
+
type: 'function',
|
|
138
|
+
function: {
|
|
139
|
+
name: 'speak',
|
|
140
|
+
description:
|
|
141
|
+
'Speak a short text aloud to the user with text-to-speech. Use it when the user asks to hear something (e.g. an audio summary). Keep it concise: the web console plays it and Telegram sends it as a voice note.',
|
|
142
|
+
parameters: {
|
|
143
|
+
type: 'object',
|
|
144
|
+
properties: {
|
|
145
|
+
texto: { type: 'string', description: 'Text to speak (long text is trimmed)' },
|
|
146
|
+
voice: { type: 'string', description: 'Optional voice id, e.g. "es-AR-ElenaNeural"' },
|
|
147
|
+
rate: { type: 'string', description: 'Optional speed, e.g. "+10%"' },
|
|
148
|
+
},
|
|
149
|
+
required: ['texto'],
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
},
|
|
136
153
|
{
|
|
137
154
|
type: 'function',
|
|
138
155
|
function: {
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// Sintesis de voz (TTS) con el servicio Edge Read Aloud de Microsoft: las
|
|
2
|
+
// voces neuronales gratuitas de Edge, sin API key ni cuenta de Azure.
|
|
3
|
+
//
|
|
4
|
+
// Port del cliente de streaming de voice-tts, adaptado al WebSocket global de
|
|
5
|
+
// Node (>=22, undici): ese WebSocket acepta `origin` y `headers` como bag de
|
|
6
|
+
// opciones (extension no estandar de undici), que el servicio exige (User-Agent
|
|
7
|
+
// de Edge + token anti-abuso Sec-MS-GEC en la URL). Sin dependencias.
|
|
8
|
+
//
|
|
9
|
+
// Aqui la salida es buffered (Promise<Buffer>): las frases que habla el harness
|
|
10
|
+
// son cortas, el MP3 completo ronda los 50-150 KB y no vale la pena exponer un
|
|
11
|
+
// stream con backpressure. Trampas heredadas del original:
|
|
12
|
+
// - sin Sec-MS-GEC (SHA-256 del file time de Windows + token) el servicio da 403;
|
|
13
|
+
// - un cierre del socket antes de `turn.end` deja un MP3 "truncado pero
|
|
14
|
+
// reproducible": eso se convierte en error, no en silencio.
|
|
15
|
+
|
|
16
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
17
|
+
|
|
18
|
+
/** Token publico del cliente de Edge (via en el codigo del navegador). */
|
|
19
|
+
export const TRUSTED_CLIENT_TOKEN = '6A5AA1D4EAFF4E9FB37E23D68491D6F4';
|
|
20
|
+
|
|
21
|
+
/** Version de Edge que declaramos. Si el servicio empieza a rechazar, subirla. */
|
|
22
|
+
export const EDGE_VERSION = process.env.TTS_EDGE_VERSION ?? '143.0.3650.75';
|
|
23
|
+
|
|
24
|
+
/** Formato por defecto: MP3 a 24 kHz, 48 kbps, mono (CBR). */
|
|
25
|
+
export const FORMATO_POR_DEFECTO = 'audio-24khz-48kbitrate-mono-mp3';
|
|
26
|
+
|
|
27
|
+
/** Tope de texto por sintesis: el servicio rechaza mas de ~3000 caracteres. */
|
|
28
|
+
export const MAX_TEXTO = 2000;
|
|
29
|
+
|
|
30
|
+
/** Voz por defecto del harness. */
|
|
31
|
+
export const VOZ_POR_DEFECTO = 'es-AR-ElenaNeural';
|
|
32
|
+
|
|
33
|
+
const URL_BASE = 'wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1';
|
|
34
|
+
const URL_VOCES = `https://speech.platform.bing.com/consumer/speech/synthesize/readaloud/voices/list?trustedclienttoken=${TRUSTED_CLIENT_TOKEN}`;
|
|
35
|
+
const ORIGEN_EDGE = 'chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold';
|
|
36
|
+
const SEGUNDOS_EPOCA_WINDOWS = 11644473600;
|
|
37
|
+
const VENTANA_TICKS = 3000000000n; // 5 minutos en ticks de 100 ns
|
|
38
|
+
const DELIMITADOR_AUDIO = Buffer.from('Path:audio\r\n');
|
|
39
|
+
|
|
40
|
+
const UA_EDGE = `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${EDGE_VERSION.split('.')[0]}.0.0.0 Safari/537.36 Edg/${EDGE_VERSION.split('.')[0]}.0.0.0`;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Token anti-abuso `Sec-MS-GEC` que exige el servicio: SHA-256 del file time
|
|
44
|
+
* de Windows redondeado a 5 minutos + el client token.
|
|
45
|
+
*
|
|
46
|
+
* @param {number} [ahora] Marca de tiempo en ms (inyectable para pruebas).
|
|
47
|
+
* @returns {string} SHA-256 en mayusculas.
|
|
48
|
+
*/
|
|
49
|
+
export function generarSecMsGec(ahora = Date.now()) {
|
|
50
|
+
const segundos = Math.floor(ahora / 1000 + SEGUNDOS_EPOCA_WINDOWS);
|
|
51
|
+
const ticks = BigInt(segundos) * 10000000n;
|
|
52
|
+
const redondeado = ticks - (ticks % VENTANA_TICKS);
|
|
53
|
+
return createHash('sha256').update(`${redondeado}${TRUSTED_CLIENT_TOKEN}`, 'ascii').digest('hex').toUpperCase();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function escaparXml(valor) {
|
|
57
|
+
return String(valor).replace(/[<>&"']/g, (c) => {
|
|
58
|
+
switch (c) {
|
|
59
|
+
case '<': return '<';
|
|
60
|
+
case '>': return '>';
|
|
61
|
+
case '&': return '&';
|
|
62
|
+
case '"': return '"';
|
|
63
|
+
default: return ''';
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** SSML de una sintesis. Exportado para probar el escape y la prosodia. */
|
|
69
|
+
export function generarSsml({ voz, lang, rate, pitch, volume, texto }) {
|
|
70
|
+
const locale = lang ?? (/\w{2}-\w{2}/.exec(String(voz ?? ''))?.[0] ?? '');
|
|
71
|
+
if (!locale) throw new Error(`No se pudo deducir el locale de la voz "${voz}".`);
|
|
72
|
+
return (
|
|
73
|
+
`<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="${escaparXml(locale)}">` +
|
|
74
|
+
`<voice name="${escaparXml(voz)}">` +
|
|
75
|
+
`<prosody rate="${escaparXml(rate)}" pitch="${escaparXml(pitch)}" volume="${escaparXml(volume)}">` +
|
|
76
|
+
`${escaparXml(texto)}` +
|
|
77
|
+
'</prosody></voice></speak>'
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function urlSintesis() {
|
|
82
|
+
const parametros = new URLSearchParams({
|
|
83
|
+
TrustedClientToken: TRUSTED_CLIENT_TOKEN,
|
|
84
|
+
'Sec-MS-GEC': generarSecMsGec(),
|
|
85
|
+
'Sec-MS-GEC-Version': `1-${EDGE_VERSION}`,
|
|
86
|
+
ConnectionId: randomBytes(16).toString('hex'),
|
|
87
|
+
});
|
|
88
|
+
return `${URL_BASE}?${parametros.toString()}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Sintetiza texto y devuelve el MP3 completo.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} texto Texto a sintetizar (se recorta a MAX_TEXTO).
|
|
95
|
+
* @param {object} [opciones]
|
|
96
|
+
* @param {string} [opciones.voice] Voz (ej. "es-AR-ElenaNeural").
|
|
97
|
+
* @param {string} [opciones.rate] Velocidad, ej. "+20%".
|
|
98
|
+
* @param {string} [opciones.pitch] Tono, ej. "-10Hz".
|
|
99
|
+
* @param {string} [opciones.volume] Volumen, ej. "+0%".
|
|
100
|
+
* @param {string} [opciones.outputFormat] Formato de salida del servicio.
|
|
101
|
+
* @param {number} [opciones.idleTimeout] Maximo de ms sin frames antes de abortar.
|
|
102
|
+
* @param {AbortSignal} [opciones.signal] Para cancelar la sintesis.
|
|
103
|
+
* @returns {Promise<Buffer>} MP3.
|
|
104
|
+
*/
|
|
105
|
+
export function sintetizar(texto, opciones = {}) {
|
|
106
|
+
const {
|
|
107
|
+
voice: voz = VOZ_POR_DEFECTO,
|
|
108
|
+
rate = '+0%',
|
|
109
|
+
pitch = '+0Hz',
|
|
110
|
+
volume = '+0%',
|
|
111
|
+
outputFormat = FORMATO_POR_DEFECTO,
|
|
112
|
+
idleTimeout = 15000,
|
|
113
|
+
signal,
|
|
114
|
+
} = opciones;
|
|
115
|
+
|
|
116
|
+
const contenido = String(texto ?? '').slice(0, MAX_TEXTO).trim();
|
|
117
|
+
if (!contenido) throw new Error('El texto está vacío: no hay nada que sintetizar.');
|
|
118
|
+
if (signal?.aborted) throw new Error('La síntesis se canceló antes de empezar.');
|
|
119
|
+
if (typeof WebSocket === 'undefined') {
|
|
120
|
+
throw new Error('Este Node no trae WebSocket global (se necesita Node 22+).');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
const chunks = [];
|
|
125
|
+
const ws = new WebSocket(urlSintesis(), {
|
|
126
|
+
origin: ORIGEN_EDGE,
|
|
127
|
+
headers: {
|
|
128
|
+
Pragma: 'no-cache',
|
|
129
|
+
'Cache-Control': 'no-cache',
|
|
130
|
+
'Accept-Encoding': 'gzip, deflate, br, zstd',
|
|
131
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
132
|
+
'User-Agent': UA_EDGE,
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
ws.binaryType = 'arraybuffer';
|
|
136
|
+
|
|
137
|
+
let termino = false;
|
|
138
|
+
let vigilante = null;
|
|
139
|
+
|
|
140
|
+
const fallar = (error) => {
|
|
141
|
+
if (termino) return;
|
|
142
|
+
termino = true;
|
|
143
|
+
clearTimeout(vigilante);
|
|
144
|
+
signal?.removeEventListener('abort', alAbortar);
|
|
145
|
+
try {
|
|
146
|
+
ws.close();
|
|
147
|
+
} catch {
|
|
148
|
+
// ya cerrado
|
|
149
|
+
}
|
|
150
|
+
reject(error);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const alAbortar = () => fallar(new Error('La síntesis se canceló.'));
|
|
154
|
+
const vigilar = () => {
|
|
155
|
+
clearTimeout(vigilante);
|
|
156
|
+
vigilante = setTimeout(() => fallar(new Error(`El servicio no envió audio en ${idleTimeout} ms.`)), idleTimeout);
|
|
157
|
+
vigilante.unref?.();
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
signal?.addEventListener('abort', alAbortar, { once: true });
|
|
161
|
+
vigilar();
|
|
162
|
+
|
|
163
|
+
ws.addEventListener('open', () => {
|
|
164
|
+
ws.send(
|
|
165
|
+
'Content-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n' +
|
|
166
|
+
JSON.stringify({
|
|
167
|
+
context: {
|
|
168
|
+
synthesis: {
|
|
169
|
+
audio: {
|
|
170
|
+
metadataoptions: { sentenceBoundaryEnabled: false, wordBoundaryEnabled: false },
|
|
171
|
+
outputFormat,
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
})
|
|
176
|
+
);
|
|
177
|
+
ws.send(
|
|
178
|
+
`X-RequestId:${randomBytes(16).toString('hex')}\r\nContent-Type:application/ssml+xml\r\nPath:ssml\r\n\r\n` +
|
|
179
|
+
generarSsml({ voz, rate, pitch, volume, texto: contenido })
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
ws.addEventListener('message', (event) => {
|
|
184
|
+
vigilar();
|
|
185
|
+
if (event.data instanceof ArrayBuffer) {
|
|
186
|
+
const frame = Buffer.from(event.data);
|
|
187
|
+
const corte = frame.indexOf(DELIMITADOR_AUDIO);
|
|
188
|
+
if (corte === -1) return;
|
|
189
|
+
const audio = frame.subarray(corte + DELIMITADOR_AUDIO.length);
|
|
190
|
+
if (audio.length) chunks.push(Buffer.from(audio));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const frame = typeof event.data === 'string' ? event.data : Buffer.from(event.data).toString();
|
|
194
|
+
if (frame.includes('Path:turn.end')) {
|
|
195
|
+
termino = true;
|
|
196
|
+
clearTimeout(vigilante);
|
|
197
|
+
signal?.removeEventListener('abort', alAbortar);
|
|
198
|
+
ws.close();
|
|
199
|
+
if (!chunks.length) reject(new Error('El servicio cerró sin entregar audio.'));
|
|
200
|
+
else resolve(Buffer.concat(chunks));
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
ws.addEventListener('error', (event) => {
|
|
205
|
+
fallar(new Error(`Error de WebSocket con el servicio: ${event.message ?? 'sin detalle'}`));
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
ws.addEventListener('close', () => {
|
|
209
|
+
fallar(new Error('El servicio cerró la conexión antes de terminar: el audio quedaría truncado.'));
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Catalogo de voces del servicio (endpoint publico, con cache propio).
|
|
216
|
+
*
|
|
217
|
+
* @param {string} [filtro] Prefijo de locale (ej. "es-"). Vacio = todas.
|
|
218
|
+
* @param {object} [opciones]
|
|
219
|
+
* @param {AbortSignal} [opciones.signal] Para cancelar la peticion.
|
|
220
|
+
* @returns {Promise<Array<object>>} Voces que empiezan con el filtro.
|
|
221
|
+
*/
|
|
222
|
+
export async function listarVoces(filtro = 'es-', { signal } = {}) {
|
|
223
|
+
const respuesta = await fetch(URL_VOCES, {
|
|
224
|
+
headers: { 'User-Agent': UA_EDGE, Accept: 'application/json' },
|
|
225
|
+
signal,
|
|
226
|
+
});
|
|
227
|
+
if (!respuesta.ok) throw new Error(`El servicio de voces respondió HTTP ${respuesta.status}.`);
|
|
228
|
+
const voces = await respuesta.json();
|
|
229
|
+
if (!Array.isArray(voces)) throw new Error('La respuesta del servicio de voces no es una lista.');
|
|
230
|
+
if (!filtro) return voces;
|
|
231
|
+
return voces.filter((v) => String(v.Locale ?? '').startsWith(filtro));
|
|
232
|
+
}
|