@danieltmn/tamnora 0.8.0 → 0.9.1

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/harness/packages/cli/src/cli.js +1 -1
  3. package/harness/packages/cli/src/commands/bridge.js +180 -119
  4. package/harness/packages/cli/src/commands/server.js +26 -50
  5. package/harness/packages/cli/src/lib/daemon.js +92 -40
  6. package/harness/packages/cli/test/release.test.js +9 -0
  7. package/harness/packages/core/src/agent/modes.js +1 -1
  8. package/harness/packages/core/src/index.js +14 -1
  9. package/harness/packages/core/src/open.js +43 -0
  10. package/harness/packages/core/src/pid.js +35 -0
  11. package/harness/packages/core/src/tools/executors.js +16 -0
  12. package/harness/packages/core/src/tools/labels.js +2 -0
  13. package/harness/packages/core/src/tools/permissions.js +1 -0
  14. package/harness/packages/core/src/tools/schema.js +17 -0
  15. package/harness/packages/core/src/tts/edge.js +232 -0
  16. package/harness/packages/core/src/tunnel.js +180 -19
  17. package/harness/packages/core/test/open.test.js +19 -0
  18. package/harness/packages/core/test/tools.test.js +12 -0
  19. package/harness/packages/core/test/tts.test.js +41 -0
  20. package/harness/packages/server/public/app.js +4665 -3975
  21. package/harness/packages/server/public/index.html +62 -5
  22. package/harness/packages/server/public/reconcile.js +121 -0
  23. package/harness/packages/server/public/relayClient.js +170 -0
  24. package/harness/packages/server/public/runStream.js +207 -0
  25. package/harness/packages/server/public/styles.css +142 -5
  26. package/harness/packages/server/src/auth.js +29 -5
  27. package/harness/packages/server/src/lib/agentRun.js +77 -0
  28. package/harness/packages/server/src/lib/announcer.js +92 -0
  29. package/harness/packages/server/src/lib/approvals.js +25 -3
  30. package/harness/packages/server/src/lib/channels/telegram.js +85 -20
  31. package/harness/packages/server/src/lib/ffmpeg.js +48 -0
  32. package/harness/packages/server/src/lib/procs.js +3 -18
  33. package/harness/packages/server/src/lib/runManager.js +24 -2
  34. package/harness/packages/server/src/lib/sessionView.js +43 -0
  35. package/harness/packages/server/src/lib/tasks.js +4 -9
  36. package/harness/packages/server/src/lib/ttsSettings.js +42 -0
  37. package/harness/packages/server/src/lib/workspace.js +37 -1
  38. package/harness/packages/server/src/routes/chat.js +48 -47
  39. package/harness/packages/server/src/routes/events.js +35 -0
  40. package/harness/packages/server/src/routes/files.js +22 -26
  41. package/harness/packages/server/src/routes/folders.js +9 -17
  42. package/harness/packages/server/src/routes/git.js +6 -14
  43. package/harness/packages/server/src/routes/procs.js +14 -16
  44. package/harness/packages/server/src/routes/sessions.js +12 -19
  45. package/harness/packages/server/src/routes/shell.js +5 -9
  46. package/harness/packages/server/src/routes/state.js +32 -0
  47. package/harness/packages/server/src/routes/tts.js +82 -0
  48. package/harness/packages/server/src/server.js +21 -1
  49. package/harness/packages/server/test/announcer.test.js +51 -0
  50. package/harness/packages/server/test/reconcile.test.js +146 -0
  51. package/harness/packages/server/test/relay.test.js +219 -0
  52. package/harness/packages/server/test/runStream.test.js +245 -0
  53. package/harness/packages/server/test/security.test.js +252 -0
  54. package/harness/packages/server/test/server.test.js +511 -1
  55. package/harness/packages/server/test/telegram-voz.test.js +156 -0
  56. package/harness/packages/server/test/tts.test.js +133 -0
  57. 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
- bridgeLogPath,
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
- if (!pid) return;
13
- try {
14
- if (process.platform === 'win32') {
15
- spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true });
16
- } else {
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
- process.kill(-pid, 'SIGTERM');
35
+ return JSON.parse(readFileSync(file, 'utf8'));
19
36
  } catch {
20
- /* sin grupo propio */
37
+ return null;
21
38
  }
39
+ },
40
+ write(data) {
41
+ ensureHome();
22
42
  try {
23
- process.kill(pid, 'SIGTERM');
43
+ writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
24
44
  } catch {
25
- /* ya no esta */
45
+ /* sin estado no detenemos nada */
26
46
  }
27
- }
28
- } catch {
29
- /* ya no esta */
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 function pidAlive(pid) {
34
- if (!pid) return false;
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
- try {
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
- try {
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
- for (const file of [runtimePath(), pidPath()]) {
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
- unlinkSync(file);
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)
@@ -13,6 +13,7 @@ export const DEFAULT_PERMISSIONS = {
13
13
  git_diff: 'allow',
14
14
  git_log: 'allow',
15
15
  fetch_url: 'allow',
16
+ speak: 'allow',
16
17
  write: 'ask',
17
18
  edit: 'ask',
18
19
  bash: 'ask',
@@ -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 '&lt;';
60
+ case '>': return '&gt;';
61
+ case '&': return '&amp;';
62
+ case '"': return '&quot;';
63
+ default: return '&apos;';
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
+ }