@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 +7 -0
- package/harness/packages/core/src/tools/executors.js +2 -2
- package/harness/packages/core/src/tools/schema.js +1 -0
- package/harness/packages/server/public/app.js +66 -10
- package/harness/packages/server/public/index.html +11 -6
- package/harness/packages/server/public/styles.css +25 -1
- package/harness/packages/server/src/lib/channels/telegram.js +8 -3
- package/harness/packages/server/src/lib/ttsSettings.js +11 -0
- package/harness/packages/server/src/routes/tts.js +4 -2
- package/harness/packages/server/test/tts.test.js +5 -1
- package/package.json +1 -1
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(
|
|
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.
|
|
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
|
-
<
|
|
740
|
-
<
|
|
741
|
-
<
|
|
742
|
-
|
|
743
|
-
|
|
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, {
|
|
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