@danieltmn/tamnora 0.18.0 → 0.19.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 CHANGED
@@ -5,6 +5,13 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
5
5
  El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
6
6
  y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
7
7
 
8
+ ## [0.19.0] - 2026-10-12
9
+
10
+ ### Cambios
11
+
12
+ - fix(consola): permiso y URL de preview legibles en movil
13
+ - feat(voz): velocidad y tono como rangos numericos
14
+
8
15
  ## [0.18.0] - 2026-10-12
9
16
 
10
17
  ### Agregado
@@ -422,11 +422,11 @@ const MAX_SPEAK_TEXT = 1200;
422
422
  * son del cliente (la consola lo reproduce y Telegram lo manda como nota de
423
423
  * voz), asi que aca solo se normaliza el pedido y se acota el largo.
424
424
  */
425
- export function speakTool(_root, { texto, voice, rate } = {}) {
425
+ export function speakTool(_root, { texto, voice, rate, pitch } = {}) {
426
426
  const value = String(texto ?? '').replace(/\s+/g, ' ').trim();
427
427
  if (!value) return 'Error: falta "texto".';
428
428
  const recortado = value.length > MAX_SPEAK_TEXT ? `${value.slice(0, MAX_SPEAK_TEXT - 1).trimEnd()}…` : value;
429
- const opciones = [voice, rate].filter(Boolean).join(', ');
429
+ const opciones = [voice, rate, pitch].filter(Boolean).join(', ');
430
430
  return `Audio pedido${opciones ? ` (${opciones})` : ''}: "${recortado}". El cliente lo reproduce o lo envía como nota de voz.`;
431
431
  }
432
432
 
@@ -145,6 +145,7 @@ export const TOOL_DEFINITIONS = [
145
145
  texto: { type: 'string', description: 'Text to speak (long text is trimmed)' },
146
146
  voice: { type: 'string', description: 'Optional voice id, e.g. "es-AR-ElenaNeural"' },
147
147
  rate: { type: 'string', description: 'Optional speed, e.g. "+10%"' },
148
+ pitch: { type: 'string', description: 'Optional tone, e.g. "-10Hz"' },
148
149
  },
149
150
  required: ['texto'],
150
151
  },
@@ -1367,7 +1367,7 @@ async function loadFullToolResult(entry, message) {
1367
1367
  function speakControl(args) {
1368
1368
  const texto = String(args?.texto ?? '').trim();
1369
1369
  if (!texto) return null;
1370
- const btn = button('', () => reproducirVoz(texto, { voice: args?.voice, rate: args?.rate, label: 'voz' }).catch(() => {}), 'tool-audio-btn');
1370
+ const btn = button('', () => reproducirVoz(texto, { voice: args?.voice, rate: args?.rate, pitch: args?.pitch, label: 'voz' }).catch(() => {}), 'tool-audio-btn');
1371
1371
  btn.append(icon('play', 13), el('span', '', 'escuchar'));
1372
1372
  return btn;
1373
1373
  }
@@ -2366,7 +2366,7 @@ function handleChatEvent(event, data, turn) {
2366
2366
  turn.lastTool = addToolCard(target, data.name, data.args, { callId: data.id, summary: data.summary });
2367
2367
  if (turn.live && data.name === 'speak') {
2368
2368
  const texto = String(data.args?.texto ?? '').trim();
2369
- if (texto) reproducirVoz(texto, { voice: data.args?.voice, rate: data.args?.rate, label: 'voz' }).catch(() => {});
2369
+ if (texto) reproducirVoz(texto, { voice: data.args?.voice, rate: data.args?.rate, pitch: data.args?.pitch, label: 'voz' }).catch(() => {});
2370
2370
  }
2371
2371
  if (turn.live && data.name === 'preview_url' && data.args?.url) {
2372
2372
  setExplorerMode('preview');
@@ -4050,6 +4050,9 @@ const tgStatusEl = $('#tg-status');
4050
4050
  const vozEnabledEl = $('#voz-enabled');
4051
4051
  const vozVoiceEl = $('#voz-voice');
4052
4052
  const vozRateEl = $('#voz-rate');
4053
+ const vozRateValEl = $('#voz-rate-val');
4054
+ const vozPitchEl = $('#voz-pitch');
4055
+ const vozPitchValEl = $('#voz-pitch-val');
4053
4056
  const vozAnnStartEl = $('#voz-ann-start');
4054
4057
  const vozAnnToolsEl = $('#voz-ann-tools');
4055
4058
  const vozAnnEndEl = $('#voz-ann-end');
@@ -4645,6 +4648,7 @@ const voz = {
4645
4648
  enabled: false,
4646
4649
  voice: 'es-AR-ElenaNeural',
4647
4650
  rate: '+0%',
4651
+ pitch: '+0Hz',
4648
4652
  announce: { start: true, tools: true, end: true, advice: true, answer: false, summary: false },
4649
4653
  },
4650
4654
  };
@@ -4689,13 +4693,53 @@ function ensurePlayer() {
4689
4693
  });
4690
4694
  }
4691
4695
 
4696
+ /** Convierte la velocidad guardada ("+20%") al valor del slider (20). */
4697
+ function rateToSlider(rate) {
4698
+ const m = /^([+-]?)(\d+)%$/.exec(String(rate ?? '').trim());
4699
+ return m ? (m[1] === '-' ? -1 : 1) * Number(m[2]) : 0;
4700
+ }
4701
+
4702
+ /** Convierte el valor del slider (-20) a la velocidad guardada. */
4703
+ function sliderToRate(n) {
4704
+ const v = Math.max(-100, Math.min(100, Math.round(Number(n) || 0)));
4705
+ return v === 0 ? '+0%' : `${v > 0 ? '+' : '-'}${Math.abs(v)}%`;
4706
+ }
4707
+
4708
+ /** Convierte el tono guardado ("-10Hz") al valor del slider (-10). */
4709
+ function pitchToSlider(pitch) {
4710
+ const m = /^([+-]?)(\d+)Hz$/.exec(String(pitch ?? '').trim());
4711
+ return m ? (m[1] === '-' ? -1 : 1) * Number(m[2]) : 0;
4712
+ }
4713
+
4714
+ /** Convierte el valor del slider (-10) al tono guardado. */
4715
+ function sliderToPitch(n) {
4716
+ const v = Math.max(-50, Math.min(50, Math.round(Number(n) || 0)));
4717
+ return v === 0 ? '+0Hz' : `${v > 0 ? '+' : '-'}${Math.abs(v)}Hz`;
4718
+ }
4719
+
4720
+ function etiquetaRate(n) {
4721
+ const v = Math.round(Number(n) || 0);
4722
+ return v === 0 ? 'normal' : `${v > 0 ? '+' : '−'}${Math.abs(v)}%`;
4723
+ }
4724
+
4725
+ function etiquetaPitch(n) {
4726
+ const v = Math.round(Number(n) || 0);
4727
+ return v === 0 ? 'normal' : `${v > 0 ? '+' : '−'}${Math.abs(v)} Hz`;
4728
+ }
4729
+
4730
+ /** Refresca la etiqueta numérica de los sliders de voz. */
4731
+ function renderVozSliders() {
4732
+ if (vozRateValEl && vozRateEl) vozRateValEl.textContent = etiquetaRate(vozRateEl.value);
4733
+ if (vozPitchValEl && vozPitchEl) vozPitchValEl.textContent = etiquetaPitch(vozPitchEl.value);
4734
+ }
4735
+
4692
4736
  /** Encola un audio. La promesa se resuelve al terminar (o al detenerlo). */
4693
- function enqueueSpeech({ texto, voice, rate, label = 'audio', maxAgeMs = 0 } = {}) {
4737
+ function enqueueSpeech({ texto, voice, rate, pitch, label = 'audio', maxAgeMs = 0 } = {}) {
4694
4738
  const frase = String(texto ?? '').trim();
4695
4739
  if (!frase) return Promise.resolve();
4696
4740
  ensurePlayer();
4697
4741
  return new Promise((resolve, reject) => {
4698
- ap.queue.push({ texto: frase, voice, rate, label, createdAt: Date.now(), maxAgeMs, resolve, reject });
4742
+ ap.queue.push({ texto: frase, voice, rate, pitch, label, createdAt: Date.now(), maxAgeMs, resolve, reject });
4699
4743
  if (ap.current) renderAudio();
4700
4744
  else playNextAudio();
4701
4745
  });
@@ -4717,6 +4761,7 @@ function playNextAudio() {
4717
4761
  texto: item.texto,
4718
4762
  voice: item.voice ?? voz.settings.voice,
4719
4763
  rate: item.rate ?? voz.settings.rate,
4764
+ pitch: item.pitch ?? voz.settings.pitch,
4720
4765
  });
4721
4766
  ap.audio.src = `/api/tts?${params}`;
4722
4767
  ap.audio.play().catch(() => {
@@ -4768,8 +4813,8 @@ function renderAudio() {
4768
4813
  }
4769
4814
 
4770
4815
  /** Reproduce una frase contra el endpoint de sintesis del server. */
4771
- function reproducirVoz(frase, { voice, rate, label, maxAgeMs } = {}) {
4772
- return enqueueSpeech({ texto: frase, voice, rate, label, maxAgeMs });
4816
+ function reproducirVoz(frase, { voice, rate, pitch, label, maxAgeMs } = {}) {
4817
+ return enqueueSpeech({ texto: frase, voice, rate, pitch, label, maxAgeMs });
4773
4818
  }
4774
4819
 
4775
4820
  /**
@@ -4824,6 +4869,7 @@ async function aplicarVoz(data) {
4824
4869
  enabled: data.enabled === true,
4825
4870
  voice: data.voice || 'es-AR-ElenaNeural',
4826
4871
  rate: data.rate || '+0%',
4872
+ pitch: data.pitch || '+0Hz',
4827
4873
  announce: {
4828
4874
  start: data.announce?.start !== false,
4829
4875
  tools: data.announce?.tools !== false,
@@ -4844,7 +4890,9 @@ async function aplicarVoz(data) {
4844
4890
  }
4845
4891
  vozVoiceEl.value = voz.settings.voice;
4846
4892
  }
4847
- if (vozRateEl) vozRateEl.value = voz.settings.rate;
4893
+ if (vozRateEl) vozRateEl.value = rateToSlider(voz.settings.rate);
4894
+ if (vozPitchEl) vozPitchEl.value = pitchToSlider(voz.settings.pitch);
4895
+ renderVozSliders();
4848
4896
  if (vozAnnStartEl) vozAnnStartEl.checked = voz.settings.announce.start;
4849
4897
  if (vozAnnToolsEl) vozAnnToolsEl.checked = voz.settings.announce.tools;
4850
4898
  if (vozAnnEndEl) vozAnnEndEl.checked = voz.settings.announce.end;
@@ -4869,7 +4917,8 @@ async function submitVoz() {
4869
4917
  const body = {
4870
4918
  enabled: vozEnabledEl?.checked === true,
4871
4919
  voice: vozVoiceEl?.value,
4872
- rate: vozRateEl?.value,
4920
+ rate: sliderToRate(vozRateEl?.value),
4921
+ pitch: sliderToPitch(vozPitchEl?.value),
4873
4922
  announce: {
4874
4923
  start: vozAnnStartEl?.checked !== false,
4875
4924
  tools: vozAnnToolsEl?.checked !== false,
@@ -5043,10 +5092,15 @@ trTestBtn?.addEventListener('click', toggleTrTest);
5043
5092
 
5044
5093
  async function testVoz() {
5045
5094
  setStatus('#voz-msg', 'sintetizando…');
5095
+ const trato = ($('#perfil-address')?.value || '').trim();
5096
+ const frase = trato
5097
+ ? `${trato}, esta sería la voz predeterminada para mí`
5098
+ : 'Esta sería la voz predeterminada para mí';
5046
5099
  try {
5047
- await reproducirVoz('Hola!, soy tu agente de IA, y así es la voz con la que cuento lo que estoy haciendo. ¿Qué te parece?', {
5100
+ await reproducirVoz(frase, {
5048
5101
  voice: vozVoiceEl?.value,
5049
- rate: vozRateEl?.value,
5102
+ rate: sliderToRate(vozRateEl?.value),
5103
+ pitch: sliderToPitch(vozPitchEl?.value),
5050
5104
  });
5051
5105
  setStatus('#voz-msg', 'sonó', 'ok');
5052
5106
  } catch (error) {
@@ -5416,6 +5470,8 @@ if (mcpListEl) {
5416
5470
  $('#tg-test')?.addEventListener('click', testChannel);
5417
5471
  $('#voz-save')?.addEventListener('click', submitVoz);
5418
5472
  $('#voz-test')?.addEventListener('click', testVoz);
5473
+ vozRateEl?.addEventListener('input', renderVozSliders);
5474
+ vozPitchEl?.addEventListener('input', renderVozSliders);
5419
5475
  $('#asesor-save')?.addEventListener('click', submitAsesor);
5420
5476
  $('#asesor-test')?.addEventListener('click', testAsesor);
5421
5477
  $('#mcp-close').addEventListener('click', closeMcpForm);
@@ -37,7 +37,7 @@
37
37
  </head>
38
38
  <body>
39
39
  <header class="topbar">
40
- <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.18.0</span></div>
40
+ <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.19.0</span></div>
41
41
  <nav class="tabs">
42
42
  <button class="tab active" data-tab="chat">chat</button>
43
43
  <button class="tab" data-tab="providers">proveedores</button>
@@ -736,11 +736,16 @@
736
736
  <select id="voz-voice" title="voz del catálogo de Edge"></select>
737
737
  </label>
738
738
  <label class="field"><span>velocidad</span>
739
- <select id="voz-rate" title="velocidad del habla">
740
- <option value="-20%">más lenta</option>
741
- <option value="+0%">normal</option>
742
- <option value="+20%">más rápida</option>
743
- </select>
739
+ <div class="voz-range">
740
+ <input id="voz-rate" type="range" min="-100" max="100" step="5" value="0" title="velocidad del habla (%)" />
741
+ <output id="voz-rate-val" for="voz-rate">normal</output>
742
+ </div>
743
+ </label>
744
+ <label class="field"><span>tono</span>
745
+ <div class="voz-range">
746
+ <input id="voz-pitch" type="range" min="-50" max="50" step="5" value="0" title="tono de la voz (Hz)" />
747
+ <output id="voz-pitch-val" for="voz-pitch">normal</output>
748
+ </div>
744
749
  </label>
745
750
  <div class="voz-announce">
746
751
  <span class="muted">qué cuenta:</span>
@@ -822,7 +822,7 @@ body.resizing * { cursor: col-resize !important; }
822
822
  }
823
823
  .perm-card .perm-head { display: flex; gap: 7px; align-items: center; color: var(--warn); font-weight: 700; }
824
824
  .perm-card .perm-args { color: var(--muted); margin: 5px 0 9px; white-space: pre-wrap; word-break: break-word; max-height: 7.5em; overflow: auto; }
825
- .perm-card .perm-actions { display: flex; align-items: center; gap: 8px; }
825
+ .perm-card .perm-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
826
826
  .perm-card .spacer { flex: 1; }
827
827
  .perm-card.approved { border-color: var(--ok); background: rgba(63, 185, 80, 0.06); }
828
828
  .perm-card.approved .perm-head { color: var(--ok); }
@@ -1320,6 +1320,9 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 0.92em; }
1320
1320
 
1321
1321
  /* ajustes: voz (TTS) -------------------------------------------------------- */
1322
1322
  .voz-announce { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); padding: 8px 0; }
1323
+ .voz-range { display: flex; align-items: center; gap: 12px; }
1324
+ .voz-range input[type="range"] { flex: 1; max-width: 360px; accent-color: var(--accent); cursor: pointer; }
1325
+ .voz-range output { min-width: 62px; text-align: right; font: 600 12px var(--serif); color: var(--fg); }
1323
1326
 
1324
1327
  /* ajustes: pestañas internas ---------------------------------------------- */
1325
1328
  #view-ajustes.active { display: block; overflow-y: auto; }
@@ -1888,6 +1891,27 @@ dialog.file-modal.preview-maximized {
1888
1891
  .session-tab {
1889
1892
  max-width: 160px;
1890
1893
  }
1894
+ /* Permiso: en un movil la fila de acciones aplastaba "requiere aprobación"
1895
+ a una letra por linea y los botones se salian. El veredicto pasa a su
1896
+ propia linea y los botones se reparten el ancho. */
1897
+ .perm-card .perm-head {
1898
+ flex-wrap: wrap;
1899
+ }
1900
+ .perm-card .perm-actions .muted {
1901
+ flex: 1 0 100%;
1902
+ }
1903
+ .perm-card .perm-actions .spacer {
1904
+ display: none;
1905
+ }
1906
+ .perm-card .perm-actions .btn {
1907
+ flex: 1 1 auto;
1908
+ min-height: 40px;
1909
+ }
1910
+ /* Preview: el input de la URL se aplastaba contra los botones y no se leia.
1911
+ Le damos la fila entera y los controles caen abajo. */
1912
+ .xp-bar #preview-url {
1913
+ flex: 1 1 100%;
1914
+ }
1891
1915
  }
1892
1916
 
1893
1917
  @media (max-width: 480px) {
@@ -252,12 +252,16 @@ export function startTelegramChannel(
252
252
  * (`sendVoice`, aparece como nota de voz); sin ffmpeg cae a audio MP3.
253
253
  * Un fallo del TTS no debe romper el run: se loguea y sigue.
254
254
  */
255
- async function enviarNotaDeVoz(chatId, texto, { voice, rate } = {}) {
255
+ async function enviarNotaDeVoz(chatId, texto, { voice, rate, pitch } = {}) {
256
256
  const tts = resolveTts(ctx.app);
257
257
  const frase = String(texto ?? '').trim().slice(0, MAX_TEXTO);
258
258
  if (!frase) return;
259
259
  try {
260
- const mp3 = await sintetizar(frase, { voice: voice || tts.voice, rate: rate || tts.rate });
260
+ const mp3 = await sintetizar(frase, {
261
+ voice: voice || tts.voice,
262
+ rate: rate || tts.rate,
263
+ pitch: pitch || tts.pitch,
264
+ });
261
265
  const ogg = await mp3AOgg(mp3);
262
266
  const form = new FormData();
263
267
  form.append('chat_id', chatId);
@@ -527,7 +531,7 @@ export function startTelegramChannel(
527
531
  } else if (event.type === 'tool_call') {
528
532
  activity = `${event.name} ${toolCallPayload(event).summary}`.trim();
529
533
  if (event.name === 'speak') {
530
- enviarNotaDeVoz(chatId, event.args?.texto, { voice: event.args?.voice, rate: event.args?.rate });
534
+ enviarNotaDeVoz(chatId, event.args?.texto, { voice: event.args?.voice, rate: event.args?.rate, pitch: event.args?.pitch });
531
535
  }
532
536
  } else if (event.type === 'tool_result') {
533
537
  activity = `${event.name} ✓`;
@@ -888,6 +892,7 @@ export function startTelegramChannel(
888
892
  enviarNotaDeVoz(linked, event.data?.args?.texto, {
889
893
  voice: event.data?.args?.voice,
890
894
  rate: event.data?.args?.rate,
895
+ pitch: event.data?.args?.pitch,
891
896
  });
892
897
  }
893
898
  return;
@@ -8,6 +8,7 @@ export const DEFAULT_TTS = {
8
8
  enabled: false,
9
9
  voice: VOZ_POR_DEFECTO,
10
10
  rate: '+0%',
11
+ pitch: '+0Hz',
11
12
  announce: { start: true, tools: true, end: true, advice: true, answer: false, summary: false },
12
13
  };
13
14
 
@@ -23,6 +24,7 @@ export function resolveTts(app) {
23
24
  enabled: saved.enabled === true,
24
25
  voice: typeof saved.voice === 'string' && saved.voice ? saved.voice : DEFAULT_TTS.voice,
25
26
  rate: typeof saved.rate === 'string' && saved.rate ? saved.rate : DEFAULT_TTS.rate,
27
+ pitch: typeof saved.pitch === 'string' && saved.pitch ? saved.pitch : DEFAULT_TTS.pitch,
26
28
  };
27
29
  }
28
30
 
@@ -40,3 +42,12 @@ export function sanearRate(value, fallback = '+0%') {
40
42
  if (numero === 0) return '+0%';
41
43
  return `${match[1] === '-' ? '-' : '+'}${numero}%`;
42
44
  }
45
+
46
+ /** Sanitiza un tono: "+10Hz", "-20Hz", "30Hz" -> "+30Hz". */
47
+ export function sanearPitch(value, fallback = '+0Hz') {
48
+ const match = /^([+-]?)(\d+)Hz$/.exec(String(value ?? '').trim());
49
+ if (!match) return fallback;
50
+ const numero = Math.min(50, Number(match[2]));
51
+ if (numero === 0) return '+0Hz';
52
+ return `${match[1] === '-' ? '-' : '+'}${numero}Hz`;
53
+ }
@@ -5,7 +5,7 @@
5
5
  import { sintetizar, listarVoces, MAX_TEXTO } from '../lib/core.js';
6
6
  import { readApp, writeApp } from '../lib/core.js';
7
7
  import { ffmpegDisponible } from '../lib/ffmpeg.js';
8
- import { resolveTts, sanearVoz, sanearRate } from '../lib/ttsSettings.js';
8
+ import { resolveTts, sanearVoz, sanearRate, sanearPitch } from '../lib/ttsSettings.js';
9
9
  import { sendJson, sendError, readJson } from '../lib/http.js';
10
10
 
11
11
  const cacheVoces = { at: 0, data: null, filtro: 'es-' };
@@ -37,6 +37,7 @@ export function registerTts(route, ctx) {
37
37
  if ('enabled' in body) next.enabled = body.enabled === true;
38
38
  if ('voice' in body) next.voice = sanearVoz(body.voice, actual.voice);
39
39
  if ('rate' in body) next.rate = sanearRate(body.rate, actual.rate);
40
+ if ('pitch' in body) next.pitch = sanearPitch(body.pitch, actual.pitch);
40
41
  if (body.announce && typeof body.announce === 'object') {
41
42
  const announce = { ...actual.announce };
42
43
  for (const key of ['start', 'tools', 'end', 'advice', 'answer', 'summary']) {
@@ -68,8 +69,9 @@ export function registerTts(route, ctx) {
68
69
  if (!texto) return sendError(res, 400, 'Falta "texto".');
69
70
  const voz = sanearVoz(query.get('voice'), config.voice);
70
71
  const rate = sanearRate(query.get('rate'), config.rate);
72
+ const pitch = sanearPitch(query.get('pitch'), config.pitch);
71
73
  try {
72
- const mp3 = await sintetizar(texto, { voice: voz, rate });
74
+ const mp3 = await sintetizar(texto, { voice: voz, rate, pitch });
73
75
  res.writeHead(200, {
74
76
  'Content-Type': 'audio/mpeg',
75
77
  'Content-Length': mp3.length,
@@ -75,6 +75,8 @@ test('los ajustes de voz exponen defaults y sanitizan lo guardado', async () =>
75
75
  const initial = await (await fetch(`${base}/api/tts/settings`)).json();
76
76
  assert.equal(initial.enabled, false);
77
77
  assert.equal(initial.voice, 'es-AR-ElenaNeural');
78
+ assert.equal(initial.rate, '+0%');
79
+ assert.equal(initial.pitch, '+0Hz');
78
80
  assert.equal(initial.announce.start, true);
79
81
  assert.equal(initial.announce.tools, true);
80
82
  assert.equal(initial.announce.end, true);
@@ -83,17 +85,19 @@ test('los ajustes de voz exponen defaults y sanitizan lo guardado', async () =>
83
85
  const put = await fetch(`${base}/api/tts/settings`, {
84
86
  method: 'PUT',
85
87
  headers: { 'Content-Type': 'application/json' },
86
- body: JSON.stringify({ enabled: true, voice: '<script>', rate: '150%', announce: { summary: true } }),
88
+ body: JSON.stringify({ enabled: true, voice: '<script>', rate: '150%', pitch: '999Hz', announce: { summary: true } }),
87
89
  });
88
90
  const saved = await put.json();
89
91
  assert.equal(saved.enabled, true);
90
92
  assert.equal(saved.voice, 'es-AR-ElenaNeural');
91
93
  assert.equal(saved.rate, '+100%');
94
+ assert.equal(saved.pitch, '+50Hz');
92
95
  assert.equal(saved.announce.summary, true);
93
96
 
94
97
  const reread = await (await fetch(`${base}/api/tts/settings`)).json();
95
98
  assert.equal(reread.enabled, true);
96
99
  assert.equal(reread.rate, '+100%');
100
+ assert.equal(reread.pitch, '+50Hz');
97
101
  assert.equal(reread.announce.summary, true);
98
102
  } finally {
99
103
  global.WebSocket = original;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Harness local para IAs: agente CLI-first con TUI, consola web y evaluacion. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",