@danieltmn/tamnora 0.21.2 → 0.22.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 +9 -0
- package/harness/packages/core/src/agent/runAgent.js +9 -5
- package/harness/packages/core/src/index.js +1 -0
- package/harness/packages/core/src/mcp/stdio.js +15 -0
- package/harness/packages/core/src/net/hosts.js +70 -0
- package/harness/packages/core/src/redact.js +2 -0
- package/harness/packages/core/src/tools/executors.js +19 -24
- package/harness/packages/core/src/tools/index.js +1 -1
- package/harness/packages/core/src/tools/paths.js +35 -5
- package/harness/packages/core/src/tools/permissions.js +41 -2
- package/harness/packages/core/src/tools/registry.js +8 -3
- package/harness/packages/core/src/tunnel.js +15 -5
- package/harness/packages/core/test/tools.test.js +44 -1
- package/harness/packages/server/public/app.js +343 -6754
- package/harness/packages/server/public/chat.js +1893 -0
- package/harness/packages/server/public/chrome.js +812 -0
- package/harness/packages/server/public/commands.js +403 -0
- package/harness/packages/server/public/core.js +209 -0
- package/harness/packages/server/public/explorer.js +625 -0
- package/harness/packages/server/public/index.html +19 -19
- package/harness/packages/server/public/sessions.js +274 -0
- package/harness/packages/server/public/settings.js +1076 -0
- package/harness/packages/server/public/styles.css +25 -2
- package/harness/packages/server/public/system.js +495 -0
- package/harness/packages/server/public/voice.js +1058 -0
- package/harness/packages/server/src/auth.js +12 -10
- package/harness/packages/server/src/lib/agentRun.js +6 -1
- package/harness/packages/server/src/lib/channels/telegram.js +1 -0
- package/harness/packages/server/src/lib/presence.js +12 -1
- package/harness/packages/server/src/lib/scheduler.js +11 -2
- package/harness/packages/server/src/lib/tasks.js +20 -3
- package/harness/packages/server/src/lib/workspace.js +90 -2
- package/harness/packages/server/src/routes/auth.js +8 -4
- package/harness/packages/server/src/routes/chat.js +15 -4
- package/harness/packages/server/src/routes/preview.js +10 -2
- package/harness/packages/server/src/routes/providers.js +12 -9
- package/harness/packages/server/src/server.js +19 -0
- package/harness/packages/server/test/chat.test.js +586 -0
- package/harness/packages/server/test/helpers.js +107 -0
- package/harness/packages/server/test/ops.test.js +511 -0
- package/harness/packages/server/test/runs.test.js +816 -0
- package/harness/packages/server/test/security.test.js +112 -9
- package/harness/packages/server/test/sessions.test.js +437 -0
- package/harness/packages/server/test/settings.test.js +465 -0
- package/harness/tamnora.example.json +1 -1
- package/package.json +1 -1
- package/harness/packages/server/test/server.test.js +0 -2885
|
@@ -0,0 +1,1058 @@
|
|
|
1
|
+
import { chatInput, renderFooterActivity, sendMessage } from './chat.js';
|
|
2
|
+
import { onChatInputChanged, toast } from './commands.js';
|
|
3
|
+
import { $, api, button, el, icon, state } from './core.js';
|
|
4
|
+
import { speechText } from './markdown.js';
|
|
5
|
+
import {
|
|
6
|
+
setStatus,
|
|
7
|
+
trModelEl,
|
|
8
|
+
trProviderEl,
|
|
9
|
+
vozAnnAdviceEl,
|
|
10
|
+
vozAnnAnswerEl,
|
|
11
|
+
vozAnnEndEl,
|
|
12
|
+
vozAnnStartEl,
|
|
13
|
+
vozAnnSummaryEl,
|
|
14
|
+
vozAnnToolsEl,
|
|
15
|
+
vozEnabledEl,
|
|
16
|
+
vozFfmpegEl,
|
|
17
|
+
vozPitchEl,
|
|
18
|
+
vozPitchValEl,
|
|
19
|
+
vozRateEl,
|
|
20
|
+
vozRateValEl,
|
|
21
|
+
vozVoiceEl,
|
|
22
|
+
} from './settings.js';
|
|
23
|
+
|
|
24
|
+
/* --- voz de entrada (dictado) --------------------------------------------- */
|
|
25
|
+
|
|
26
|
+
export const micBtn = $('#mic-btn');
|
|
27
|
+
|
|
28
|
+
// La cuenta 3-2-1 le da tiempo al micro a arrancar (si hablás apenas tocás se
|
|
29
|
+
// pierden las primeras palabras); la cola al cortar evita perder el final.
|
|
30
|
+
export const REC_COUNT = [3, 2, 1, 0];
|
|
31
|
+
export const REC_COUNT_STEP_MS = 500;
|
|
32
|
+
export const REC_TAIL_MS = 500;
|
|
33
|
+
|
|
34
|
+
/** Estado visual del botón de dictado: cuenta regresiva, grabando (verde) o transcribiendo. */
|
|
35
|
+
export function micVisual(button, { live = false, count = null, busy = false } = {}) {
|
|
36
|
+
if (!button) return;
|
|
37
|
+
button.classList.toggle('recording', live);
|
|
38
|
+
button.classList.toggle('busy', busy);
|
|
39
|
+
button.classList.toggle('mic-counting', count !== null);
|
|
40
|
+
const span = button.querySelector('.mic-count');
|
|
41
|
+
if (span) {
|
|
42
|
+
span.hidden = count === null;
|
|
43
|
+
span.textContent = count === null ? '' : String(count);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Cuenta regresiva antes de que empieces a hablar. Devuelve false si se canceló. */
|
|
48
|
+
export async function micCountdown(button, isCancelled) {
|
|
49
|
+
for (const count of REC_COUNT) {
|
|
50
|
+
if (isCancelled()) return false;
|
|
51
|
+
micVisual(button, { live: true, count });
|
|
52
|
+
await new Promise((resolve) => setTimeout(resolve, REC_COUNT_STEP_MS));
|
|
53
|
+
}
|
|
54
|
+
if (isCancelled()) return false;
|
|
55
|
+
micVisual(button, { live: true });
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function blobToDataUrl(blob) {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const reader = new FileReader();
|
|
62
|
+
reader.onerror = () => reject(new Error('No se pudo leer el audio'));
|
|
63
|
+
reader.onload = () => resolve(reader.result);
|
|
64
|
+
reader.readAsDataURL(blob);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function transcribirBlob(blob) {
|
|
69
|
+
const audio = await blobToDataUrl(blob);
|
|
70
|
+
const data = await api('/api/transcribe', { method: 'POST', body: { audio } });
|
|
71
|
+
return String(data?.text ?? '').trim();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Une los bloques de muestras Float32 en uno solo. */
|
|
75
|
+
export function mergeFloat32(chunks) {
|
|
76
|
+
const length = chunks.reduce((total, chunk) => total + chunk.length, 0);
|
|
77
|
+
const out = new Float32Array(length);
|
|
78
|
+
let offset = 0;
|
|
79
|
+
for (const chunk of chunks) {
|
|
80
|
+
out.set(chunk, offset);
|
|
81
|
+
offset += chunk.length;
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Remuestrea a 16 kHz mono con OfflineAudioContext (sin dependencias). */
|
|
87
|
+
export async function resampleTo16k(samples, inputRate) {
|
|
88
|
+
if (!samples.length || Math.round(inputRate) === 16000) return samples;
|
|
89
|
+
const targetLength = Math.max(1, Math.round((samples.length * 16000) / inputRate));
|
|
90
|
+
const Ctx = window.OfflineAudioContext || window.webkitOfflineAudioContext;
|
|
91
|
+
const off = new Ctx(1, targetLength, 16000);
|
|
92
|
+
const buffer = off.createBuffer(1, samples.length, inputRate);
|
|
93
|
+
buffer.copyToChannel(samples, 0);
|
|
94
|
+
const source = off.createBufferSource();
|
|
95
|
+
source.buffer = buffer;
|
|
96
|
+
source.connect(off.destination);
|
|
97
|
+
source.start();
|
|
98
|
+
const rendered = await off.startRendering();
|
|
99
|
+
return rendered.getChannelData(0);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Codifica muestras Float32 (-1..1) como WAV PCM 16-bit (lo acepta cualquier proveedor). */
|
|
103
|
+
export function encodeWav(samples, rate = 16000) {
|
|
104
|
+
const buffer = new ArrayBuffer(44 + samples.length * 2);
|
|
105
|
+
const view = new DataView(buffer);
|
|
106
|
+
const writeString = (offset, text) => {
|
|
107
|
+
for (let i = 0; i < text.length; i++) view.setUint8(offset + i, text.charCodeAt(i));
|
|
108
|
+
};
|
|
109
|
+
writeString(0, 'RIFF');
|
|
110
|
+
view.setUint32(4, 36 + samples.length * 2, true);
|
|
111
|
+
writeString(8, 'WAVE');
|
|
112
|
+
writeString(12, 'fmt ');
|
|
113
|
+
view.setUint32(16, 16, true);
|
|
114
|
+
view.setUint16(20, 1, true);
|
|
115
|
+
view.setUint16(22, 1, true);
|
|
116
|
+
view.setUint32(24, rate, true);
|
|
117
|
+
view.setUint32(28, rate * 2, true);
|
|
118
|
+
view.setUint16(32, 2, true);
|
|
119
|
+
view.setUint16(34, 16, true);
|
|
120
|
+
writeString(36, 'data');
|
|
121
|
+
view.setUint32(40, samples.length * 2, true);
|
|
122
|
+
let offset = 44;
|
|
123
|
+
for (let i = 0; i < samples.length; i++) {
|
|
124
|
+
const sample = Math.max(-1, Math.min(1, samples[i]));
|
|
125
|
+
view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
|
|
126
|
+
offset += 2;
|
|
127
|
+
}
|
|
128
|
+
return new Blob([view], { type: 'audio/wav' });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Una grabación de micrófono (motor del servidor). Graba con Web Audio y devuelve
|
|
133
|
+
* un **WAV** (no WebM): así el proveedor no depende del contenedor que produce
|
|
134
|
+
* `MediaRecorder`. `start()` abre el micro (la cuenta 3-2-1 la maneja el que llama);
|
|
135
|
+
* `stop()` espera la cola antes de cortar. La usan el micrófono del chat y el
|
|
136
|
+
* "probar" de Ajustes.
|
|
137
|
+
*/
|
|
138
|
+
export function createRecording({ silenceMs = 0, noSpeechMs = 0, maxMs = 0, speechThreshold = 0.02 } = {}) {
|
|
139
|
+
let resolveBlob;
|
|
140
|
+
const done = new Promise((resolve) => {
|
|
141
|
+
resolveBlob = resolve;
|
|
142
|
+
});
|
|
143
|
+
let stream = null;
|
|
144
|
+
let audioCtx = null;
|
|
145
|
+
let processor = null;
|
|
146
|
+
let source = null;
|
|
147
|
+
let sink = null;
|
|
148
|
+
let stopping = false;
|
|
149
|
+
let started = false;
|
|
150
|
+
let startedAt = 0;
|
|
151
|
+
let hasSpeech = false;
|
|
152
|
+
let lastLoudAt = 0;
|
|
153
|
+
let watchdog = null;
|
|
154
|
+
const chunks = [];
|
|
155
|
+
|
|
156
|
+
async function cleanup() {
|
|
157
|
+
if (watchdog) clearInterval(watchdog);
|
|
158
|
+
watchdog = null;
|
|
159
|
+
try {
|
|
160
|
+
processor?.disconnect();
|
|
161
|
+
} catch {
|
|
162
|
+
// ya desconectado
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
source?.disconnect();
|
|
166
|
+
} catch {
|
|
167
|
+
// ya desconectado
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
sink?.disconnect();
|
|
171
|
+
} catch {
|
|
172
|
+
// ya desconectado
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
await audioCtx?.close();
|
|
176
|
+
} catch {
|
|
177
|
+
// contexto ya cerrado
|
|
178
|
+
}
|
|
179
|
+
stream?.getTracks?.().forEach((track) => track.stop());
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function stopRecording() {
|
|
183
|
+
if (stopping || !started) return;
|
|
184
|
+
stopping = true;
|
|
185
|
+
if (watchdog) clearInterval(watchdog);
|
|
186
|
+
watchdog = null;
|
|
187
|
+
setTimeout(async () => {
|
|
188
|
+
const rate = audioCtx?.sampleRate || 48000;
|
|
189
|
+
const merged = mergeFloat32(chunks);
|
|
190
|
+
await cleanup();
|
|
191
|
+
const resampled = await resampleTo16k(merged, rate);
|
|
192
|
+
resolveBlob(encodeWav(resampled, 16000));
|
|
193
|
+
}, REC_TAIL_MS);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Manos libres: corta solo por silencio (tras haber hablado), si no hubo voz en
|
|
197
|
+
// `noSpeechMs` o al llegar a `maxMs`. Sin estas opciones no interviene.
|
|
198
|
+
function startWatchdog() {
|
|
199
|
+
if (!silenceMs && !noSpeechMs && !maxMs) return;
|
|
200
|
+
watchdog = setInterval(() => {
|
|
201
|
+
if (stopping || !started) return;
|
|
202
|
+
const now = Date.now();
|
|
203
|
+
if (maxMs && now - startedAt >= maxMs) return stopRecording();
|
|
204
|
+
if (!silenceMs) return;
|
|
205
|
+
if (!hasSpeech) {
|
|
206
|
+
if (noSpeechMs && now - startedAt >= noSpeechMs) stopRecording();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (now - lastLoudAt >= silenceMs) stopRecording();
|
|
210
|
+
}, 150);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
async start() {
|
|
215
|
+
const Ctx = window.AudioContext || window.webkitAudioContext;
|
|
216
|
+
if (!navigator.mediaDevices?.getUserMedia || !Ctx) {
|
|
217
|
+
throw new Error('Este navegador no soporta dictado por voz.');
|
|
218
|
+
}
|
|
219
|
+
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
220
|
+
audioCtx = new Ctx();
|
|
221
|
+
if (audioCtx.state === 'suspended') await audioCtx.resume();
|
|
222
|
+
source = audioCtx.createMediaStreamSource(stream);
|
|
223
|
+
processor = audioCtx.createScriptProcessor(4096, 1, 1);
|
|
224
|
+
processor.onaudioprocess = (event) => {
|
|
225
|
+
const frame = event.inputBuffer.getChannelData(0);
|
|
226
|
+
chunks.push(new Float32Array(frame));
|
|
227
|
+
if (!silenceMs) return;
|
|
228
|
+
let sum = 0;
|
|
229
|
+
for (let i = 0; i < frame.length; i += 2) sum += frame[i] * frame[i];
|
|
230
|
+
const rms = Math.sqrt(sum / (frame.length / 2));
|
|
231
|
+
if (rms >= speechThreshold) {
|
|
232
|
+
hasSpeech = true;
|
|
233
|
+
lastLoudAt = Date.now();
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
// El procesador solo corre si llega al destination: un gain en 0 evita el eco.
|
|
237
|
+
sink = audioCtx.createGain();
|
|
238
|
+
sink.gain.value = 0;
|
|
239
|
+
source.connect(processor);
|
|
240
|
+
processor.connect(sink);
|
|
241
|
+
sink.connect(audioCtx.destination);
|
|
242
|
+
started = true;
|
|
243
|
+
startedAt = Date.now();
|
|
244
|
+
startWatchdog();
|
|
245
|
+
},
|
|
246
|
+
get stopping() {
|
|
247
|
+
return stopping;
|
|
248
|
+
},
|
|
249
|
+
stop: stopRecording,
|
|
250
|
+
done,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export const ERRORES_DICTADO = {
|
|
255
|
+
network:
|
|
256
|
+
'el dictado del navegador necesita el servicio de voz de Google y no respondió (pasa en Brave o sin internet). Probá el motor «Servidor» en Ajustes → Transcripción.',
|
|
257
|
+
'not-allowed': 'permiso de micrófono denegado.',
|
|
258
|
+
'service-not-allowed': 'el navegador bloqueó el servicio de dictado.',
|
|
259
|
+
aborted: 'dictado cancelado.',
|
|
260
|
+
'audio-capture': 'no encontré micrófono.',
|
|
261
|
+
'no-speech': 'no escuché nada.',
|
|
262
|
+
};
|
|
263
|
+
export const DICTADO_FATAL = new Set(['network', 'not-allowed', 'service-not-allowed', 'audio-capture']);
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Dictado nativo del navegador (Web Speech API). `start()`/`stop()` controlan el
|
|
267
|
+
* ciclo. Los cortes por silencio (`no-speech`) se reconectan solos para no perder
|
|
268
|
+
* el hilo; los errores fatales (p. ej. `network`) se informan y se cortan.
|
|
269
|
+
*/
|
|
270
|
+
export function createBrowserDictation({ language, onText, onEnd, onError } = {}) {
|
|
271
|
+
const Ctor = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
272
|
+
if (!Ctor) throw new Error('Este navegador no soporta dictado nativo (probá Chrome/Edge).');
|
|
273
|
+
const rec = new Ctor();
|
|
274
|
+
rec.lang = language || 'es-AR';
|
|
275
|
+
rec.continuous = true;
|
|
276
|
+
rec.interimResults = true;
|
|
277
|
+
let finalText = '';
|
|
278
|
+
let wanted = false;
|
|
279
|
+
let fatal = false;
|
|
280
|
+
let restarts = 0;
|
|
281
|
+
|
|
282
|
+
rec.onresult = (event) => {
|
|
283
|
+
let interim = '';
|
|
284
|
+
for (let i = event.resultIndex; i < event.results.length; i++) {
|
|
285
|
+
const result = event.results[i];
|
|
286
|
+
if (result.isFinal) finalText += result[0].transcript;
|
|
287
|
+
else interim += result[0].transcript;
|
|
288
|
+
}
|
|
289
|
+
onText?.(finalText, finalText + interim);
|
|
290
|
+
};
|
|
291
|
+
rec.onerror = (event) => {
|
|
292
|
+
const code = event.error || 'unknown';
|
|
293
|
+
if (DICTADO_FATAL.has(code)) {
|
|
294
|
+
fatal = true;
|
|
295
|
+
wanted = false;
|
|
296
|
+
onError?.(new Error(ERRORES_DICTADO[code] || `error de dictado (${code}).`));
|
|
297
|
+
} else if (code !== 'no-speech' && code !== 'aborted') {
|
|
298
|
+
onError?.(new Error(ERRORES_DICTADO[code] || `error de dictado (${code}).`));
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
rec.onend = () => {
|
|
302
|
+
// Chrome corta tras una pausa: si seguimos "escuchando", reconectamos.
|
|
303
|
+
if (wanted && !fatal && restarts < 30) {
|
|
304
|
+
restarts += 1;
|
|
305
|
+
setTimeout(() => {
|
|
306
|
+
if (!wanted || fatal) return;
|
|
307
|
+
try {
|
|
308
|
+
rec.start();
|
|
309
|
+
} catch {
|
|
310
|
+
// se reintenta en el proximo onend
|
|
311
|
+
}
|
|
312
|
+
}, 300);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
wanted = false;
|
|
316
|
+
onEnd?.();
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
start() {
|
|
321
|
+
wanted = true;
|
|
322
|
+
fatal = false;
|
|
323
|
+
restarts = 0;
|
|
324
|
+
rec.start();
|
|
325
|
+
},
|
|
326
|
+
stop() {
|
|
327
|
+
wanted = false;
|
|
328
|
+
try {
|
|
329
|
+
rec.stop();
|
|
330
|
+
} catch {
|
|
331
|
+
// ya estaba detenido
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export let composerRecording = null;
|
|
338
|
+
export let browserDictation = null;
|
|
339
|
+
|
|
340
|
+
export function startBrowserMic() {
|
|
341
|
+
const base = chatInput.value.trim();
|
|
342
|
+
let dictation;
|
|
343
|
+
try {
|
|
344
|
+
dictation = createBrowserDictation({
|
|
345
|
+
language: transcribeConfig.language,
|
|
346
|
+
onText: (_final, combined) => {
|
|
347
|
+
chatInput.value = [base, combined.trim()].filter(Boolean).join(' ');
|
|
348
|
+
onChatInputChanged();
|
|
349
|
+
},
|
|
350
|
+
onEnd: () => {
|
|
351
|
+
if (browserDictation === dictation) browserDictation = null;
|
|
352
|
+
micVisual(micBtn);
|
|
353
|
+
},
|
|
354
|
+
onError: (error) => toast(`Dictado: ${error.message}`),
|
|
355
|
+
});
|
|
356
|
+
} catch (error) {
|
|
357
|
+
return toast(error.message);
|
|
358
|
+
}
|
|
359
|
+
browserDictation = dictation;
|
|
360
|
+
micVisual(micBtn, { live: true });
|
|
361
|
+
try {
|
|
362
|
+
dictation.start();
|
|
363
|
+
} catch (error) {
|
|
364
|
+
browserDictation = null;
|
|
365
|
+
micVisual(micBtn);
|
|
366
|
+
return toast(`Dictado: ${error.message}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export async function toggleMic() {
|
|
371
|
+
if (!micBtn) return;
|
|
372
|
+
// Con manos libres el micro lo maneja el loop: no dejamos que se pisen.
|
|
373
|
+
if (handsFree.active) return;
|
|
374
|
+
if (browserDictation) {
|
|
375
|
+
browserDictation.stop();
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (composerRecording) {
|
|
379
|
+
composerRecording.stop();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (micBtn.disabled) return;
|
|
383
|
+
if (transcribeConfig.mode === 'browser') return startBrowserMic();
|
|
384
|
+
|
|
385
|
+
const rec = createRecording();
|
|
386
|
+
micBtn.disabled = true;
|
|
387
|
+
try {
|
|
388
|
+
await rec.start();
|
|
389
|
+
} catch (error) {
|
|
390
|
+
micBtn.disabled = false;
|
|
391
|
+
micVisual(micBtn);
|
|
392
|
+
return toast(`No pude acceder al micrófono: ${error.message}`);
|
|
393
|
+
}
|
|
394
|
+
micBtn.disabled = false;
|
|
395
|
+
composerRecording = rec;
|
|
396
|
+
|
|
397
|
+
// El manejo va antes de la cuenta: si cortás durante el 3-2-1, igual transcribe.
|
|
398
|
+
rec.done.then(async (blob) => {
|
|
399
|
+
if (composerRecording === rec) composerRecording = null;
|
|
400
|
+
if (!blob.size) {
|
|
401
|
+
micVisual(micBtn);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
micVisual(micBtn, { busy: true });
|
|
405
|
+
try {
|
|
406
|
+
const text = await transcribirBlob(blob);
|
|
407
|
+
if (!text) return toast('No se entendió el audio.');
|
|
408
|
+
chatInput.value = chatInput.value.trim() ? `${chatInput.value.trim()} ${text}` : text;
|
|
409
|
+
onChatInputChanged();
|
|
410
|
+
chatInput.focus();
|
|
411
|
+
} catch (error) {
|
|
412
|
+
toast(`No pude transcribir: ${error.message}`);
|
|
413
|
+
} finally {
|
|
414
|
+
micVisual(micBtn);
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
await micCountdown(micBtn, () => rec.stopping);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/* --- manos libres: conversación por voz ----------------------------------- */
|
|
422
|
+
|
|
423
|
+
export const HANDS_FREE_SILENCE_MS = 1400; // silencio que cierra tu turno
|
|
424
|
+
export const HANDS_FREE_NOSPEECH_MS = 8000; // si no hablás, vuelve a intentar
|
|
425
|
+
export const HANDS_FREE_MAX_MS = 45000; // tope duro de una intervención
|
|
426
|
+
|
|
427
|
+
export const handsFreeBtn = $('#hands-free-btn');
|
|
428
|
+
export const handsFree = { active: false, phase: 'idle', token: 0 };
|
|
429
|
+
|
|
430
|
+
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
431
|
+
|
|
432
|
+
export function renderHandsFree() {
|
|
433
|
+
if (handsFreeBtn) {
|
|
434
|
+
handsFreeBtn.classList.toggle('hands-free-active', handsFree.active);
|
|
435
|
+
handsFreeBtn.classList.toggle('listening', handsFree.active && handsFree.phase === 'listening');
|
|
436
|
+
handsFreeBtn.setAttribute('aria-pressed', handsFree.active ? 'true' : 'false');
|
|
437
|
+
handsFreeBtn.title = handsFree.active
|
|
438
|
+
? 'manos libres: activado (tocá para desactivar)'
|
|
439
|
+
: 'manos libres: conversación por voz';
|
|
440
|
+
}
|
|
441
|
+
if (handsFree.active && handsFree.phase === 'listening') renderFooterActivity('listening');
|
|
442
|
+
else if (handsFree.active && handsFree.phase === 'thinking') renderFooterActivity('thinking');
|
|
443
|
+
else renderFooterActivity(null);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function setHandsFreePhase(phase) {
|
|
447
|
+
handsFree.phase = phase;
|
|
448
|
+
renderHandsFree();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function setHandsFree(on) {
|
|
452
|
+
const next = Boolean(on);
|
|
453
|
+
if (handsFree.active === next) return;
|
|
454
|
+
handsFree.active = next;
|
|
455
|
+
handsFree.token += 1;
|
|
456
|
+
if (next) {
|
|
457
|
+
// Cortamos un dictado manual en curso para no pisarlo.
|
|
458
|
+
composerRecording?.stop();
|
|
459
|
+
setHandsFreePhase('idle');
|
|
460
|
+
renderHandsFree();
|
|
461
|
+
runHandsFree(handsFree.token).catch(() => {});
|
|
462
|
+
} else {
|
|
463
|
+
// Cortamos lo que esté grabando para no dejar el micro abierto.
|
|
464
|
+
handsFree.recording?.stop();
|
|
465
|
+
handsFree.recording = null;
|
|
466
|
+
browserDictation?.stop();
|
|
467
|
+
setHandsFreePhase('idle');
|
|
468
|
+
renderHandsFree();
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export function toggleHandsFree() {
|
|
473
|
+
setHandsFree(!handsFree.active);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Una escucha de manos libres: graba hasta el silencio y devuelve el texto. */
|
|
477
|
+
export async function handsFreeListen(token) {
|
|
478
|
+
if (transcribeConfig.mode === 'browser') {
|
|
479
|
+
const text = await new Promise((resolve, reject) => {
|
|
480
|
+
let combined = '';
|
|
481
|
+
let dictation;
|
|
482
|
+
const finish = () => {
|
|
483
|
+
if (browserDictation === dictation) browserDictation = null;
|
|
484
|
+
resolve(combined.trim());
|
|
485
|
+
};
|
|
486
|
+
try {
|
|
487
|
+
dictation = createBrowserDictation({
|
|
488
|
+
language: transcribeConfig.language,
|
|
489
|
+
onText: (_final, all) => {
|
|
490
|
+
combined = all;
|
|
491
|
+
},
|
|
492
|
+
onEnd: finish,
|
|
493
|
+
onError: reject,
|
|
494
|
+
});
|
|
495
|
+
} catch (error) {
|
|
496
|
+
reject(error);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
browserDictation = dictation;
|
|
500
|
+
dictation.start();
|
|
501
|
+
setTimeout(() => {
|
|
502
|
+
if (browserDictation === dictation) dictation.stop();
|
|
503
|
+
}, HANDS_FREE_MAX_MS);
|
|
504
|
+
});
|
|
505
|
+
return token === handsFree.token ? text : '';
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const rec = createRecording({
|
|
509
|
+
silenceMs: HANDS_FREE_SILENCE_MS,
|
|
510
|
+
noSpeechMs: HANDS_FREE_NOSPEECH_MS,
|
|
511
|
+
maxMs: HANDS_FREE_MAX_MS,
|
|
512
|
+
});
|
|
513
|
+
await rec.start();
|
|
514
|
+
if (token !== handsFree.token) {
|
|
515
|
+
rec.stop();
|
|
516
|
+
return '';
|
|
517
|
+
}
|
|
518
|
+
handsFree.recording = rec;
|
|
519
|
+
setHandsFreePhase('listening');
|
|
520
|
+
const blob = await rec.done;
|
|
521
|
+
handsFree.recording = null;
|
|
522
|
+
if (token !== handsFree.token || !blob.size) return '';
|
|
523
|
+
setHandsFreePhase('thinking');
|
|
524
|
+
try {
|
|
525
|
+
return await transcribirBlob(blob);
|
|
526
|
+
} catch (error) {
|
|
527
|
+
toast(`No pude transcribir: ${error.message}`);
|
|
528
|
+
return '';
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Loop conversacional: escuchar → enviar → esperar la voz → repetir. */
|
|
533
|
+
export async function runHandsFree(token) {
|
|
534
|
+
while (handsFree.active && token === handsFree.token) {
|
|
535
|
+
if (!state.sessionId) {
|
|
536
|
+
toast('Manos libres necesita una sesión abierta.');
|
|
537
|
+
setHandsFree(false);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
if (state.panes.get(state.sessionId)?.working) {
|
|
541
|
+
await sleep(400);
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
let text = '';
|
|
546
|
+
try {
|
|
547
|
+
text = await handsFreeListen(token);
|
|
548
|
+
} catch (error) {
|
|
549
|
+
if (token !== handsFree.token) return;
|
|
550
|
+
toast(`Manos libres: ${error.message}`);
|
|
551
|
+
setHandsFree(false);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
if (!handsFree.active || token !== handsFree.token) return;
|
|
555
|
+
if (!text) continue; // silencio o no se entendió: otra vuelta
|
|
556
|
+
|
|
557
|
+
chatInput.value = text;
|
|
558
|
+
onChatInputChanged();
|
|
559
|
+
setHandsFreePhase('thinking');
|
|
560
|
+
await sendMessage();
|
|
561
|
+
if (!handsFree.active || token !== handsFree.token) return;
|
|
562
|
+
// Esperamos a que termine de hablar antes de volver a escuchar (evita el eco).
|
|
563
|
+
await whenAudioIdle();
|
|
564
|
+
await sleep(250);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/* --- ajustes: voz (TTS) ---------------------------------------------------- */
|
|
569
|
+
|
|
570
|
+
export const voz = {
|
|
571
|
+
settings: {
|
|
572
|
+
enabled: false,
|
|
573
|
+
voice: 'es-AR-ElenaNeural',
|
|
574
|
+
rate: '+0%',
|
|
575
|
+
pitch: '+0Hz',
|
|
576
|
+
announce: { start: true, tools: true, end: true, advice: true, answer: false, summary: false },
|
|
577
|
+
},
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Un único reproductor para TODO el audio (narración, tool `speak`,
|
|
582
|
+
* `/audioresumen`, prueba de voz): encola y reproduce de a uno, en orden, con
|
|
583
|
+
* controles visibles para pausar o detener. Antes cada audio creaba su propio
|
|
584
|
+
* `<audio>` y, si el modelo pedía dos, sonaban encimados y sin forma de pararlos.
|
|
585
|
+
*/
|
|
586
|
+
export const ap = { el: $('#audio-player'), audio: new Audio(), queue: [], current: null, ready: false };
|
|
587
|
+
|
|
588
|
+
// Avisa cuando el reproductor queda en silencio (cola vacia). Lo usa manos libres
|
|
589
|
+
// para saber cuando el asistente termino de hablar y volver a escuchar.
|
|
590
|
+
export const audioIdleWaiters = [];
|
|
591
|
+
export function whenAudioIdle() {
|
|
592
|
+
if (!ap.current && !ap.queue.length) return Promise.resolve();
|
|
593
|
+
return new Promise((resolve) => audioIdleWaiters.push(resolve));
|
|
594
|
+
}
|
|
595
|
+
export function notifyAudioIdle() {
|
|
596
|
+
if (ap.current || ap.queue.length) return;
|
|
597
|
+
for (const resolve of audioIdleWaiters.splice(0)) resolve();
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export function ensurePlayer() {
|
|
601
|
+
if (!ap.el || ap.ready) return;
|
|
602
|
+
ap.ready = true;
|
|
603
|
+
ap.toggle = button('', () => toggleAudio(), 'ap-btn');
|
|
604
|
+
ap.iconEl = el('span', 'ap-icon');
|
|
605
|
+
ap.toggle.appendChild(ap.iconEl);
|
|
606
|
+
ap.labelEl = el('span', 'ap-label');
|
|
607
|
+
ap.barEl = el('div', 'ap-bar');
|
|
608
|
+
const progress = el('div', 'ap-progress');
|
|
609
|
+
progress.appendChild(ap.barEl);
|
|
610
|
+
const info = el('div', 'ap-info');
|
|
611
|
+
info.append(ap.labelEl, progress);
|
|
612
|
+
ap.queueEl = el('span', 'ap-queue');
|
|
613
|
+
ap.stopBtn = button('', () => stopAudio(), 'ap-btn ap-stop');
|
|
614
|
+
ap.stopBtn.title = 'detener';
|
|
615
|
+
ap.stopBtn.setAttribute('aria-label', 'detener audio');
|
|
616
|
+
ap.stopBtn.appendChild(icon('square', 14));
|
|
617
|
+
ap.el.append(ap.toggle, info, ap.queueEl, ap.stopBtn);
|
|
618
|
+
|
|
619
|
+
ap.audio.addEventListener('ended', () => advanceAudio(ap.current));
|
|
620
|
+
ap.audio.addEventListener('error', () => {
|
|
621
|
+
if (ap.current && ap.audio.getAttribute('src')) advanceAudio(ap.current, new Error('No se pudo reproducir el audio.'));
|
|
622
|
+
});
|
|
623
|
+
ap.audio.addEventListener('play', renderAudio);
|
|
624
|
+
ap.audio.addEventListener('pause', renderAudio);
|
|
625
|
+
ap.audio.addEventListener('timeupdate', () => {
|
|
626
|
+
const { duration, currentTime } = ap.audio;
|
|
627
|
+
if (!Number.isFinite(duration) || duration <= 0) return;
|
|
628
|
+
ap.barEl.style.width = `${Math.min(100, (currentTime / duration) * 100)}%`;
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** Convierte la velocidad guardada ("+20%") al valor del slider (20). */
|
|
633
|
+
export function rateToSlider(rate) {
|
|
634
|
+
const m = /^([+-]?)(\d+)%$/.exec(String(rate ?? '').trim());
|
|
635
|
+
return m ? (m[1] === '-' ? -1 : 1) * Number(m[2]) : 0;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Convierte el valor del slider (-20) a la velocidad guardada. */
|
|
639
|
+
export function sliderToRate(n) {
|
|
640
|
+
const v = Math.max(-100, Math.min(100, Math.round(Number(n) || 0)));
|
|
641
|
+
return v === 0 ? '+0%' : `${v > 0 ? '+' : '-'}${Math.abs(v)}%`;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Convierte el tono guardado ("-10Hz") al valor del slider (-10). */
|
|
645
|
+
export function pitchToSlider(pitch) {
|
|
646
|
+
const m = /^([+-]?)(\d+)Hz$/.exec(String(pitch ?? '').trim());
|
|
647
|
+
return m ? (m[1] === '-' ? -1 : 1) * Number(m[2]) : 0;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** Convierte el valor del slider (-10) al tono guardado. */
|
|
651
|
+
export function sliderToPitch(n) {
|
|
652
|
+
const v = Math.max(-50, Math.min(50, Math.round(Number(n) || 0)));
|
|
653
|
+
return v === 0 ? '+0Hz' : `${v > 0 ? '+' : '-'}${Math.abs(v)}Hz`;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
export function etiquetaRate(n) {
|
|
657
|
+
const v = Math.round(Number(n) || 0);
|
|
658
|
+
return v === 0 ? 'normal' : `${v > 0 ? '+' : '−'}${Math.abs(v)}%`;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
export function etiquetaPitch(n) {
|
|
662
|
+
const v = Math.round(Number(n) || 0);
|
|
663
|
+
return v === 0 ? 'normal' : `${v > 0 ? '+' : '−'}${Math.abs(v)} Hz`;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** Refresca la etiqueta numérica de los sliders de voz. */
|
|
667
|
+
export function renderVozSliders() {
|
|
668
|
+
if (vozRateValEl && vozRateEl) vozRateValEl.textContent = etiquetaRate(vozRateEl.value);
|
|
669
|
+
if (vozPitchValEl && vozPitchEl) vozPitchValEl.textContent = etiquetaPitch(vozPitchEl.value);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** Encola un audio. La promesa se resuelve al terminar (o al detenerlo). */
|
|
673
|
+
export function enqueueSpeech({ texto, voice, rate, pitch, label = 'audio', maxAgeMs = 0 } = {}) {
|
|
674
|
+
const frase = String(texto ?? '').trim();
|
|
675
|
+
if (!frase) return Promise.resolve();
|
|
676
|
+
ensurePlayer();
|
|
677
|
+
return new Promise((resolve, reject) => {
|
|
678
|
+
ap.queue.push({ texto: frase, voice, rate, pitch, label, createdAt: Date.now(), maxAgeMs, resolve, reject });
|
|
679
|
+
if (ap.current) renderAudio();
|
|
680
|
+
else playNextAudio();
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
export function playNextAudio() {
|
|
685
|
+
const item = ap.queue.shift();
|
|
686
|
+
if (!item) {
|
|
687
|
+
ap.current = null;
|
|
688
|
+
renderAudio();
|
|
689
|
+
notifyAudioIdle();
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
if (item.maxAgeMs && Date.now() - item.createdAt > item.maxAgeMs) {
|
|
693
|
+
item.resolve?.();
|
|
694
|
+
return playNextAudio();
|
|
695
|
+
}
|
|
696
|
+
ap.current = item;
|
|
697
|
+
const params = new URLSearchParams({
|
|
698
|
+
texto: item.texto,
|
|
699
|
+
voice: item.voice ?? voz.settings.voice,
|
|
700
|
+
rate: item.rate ?? voz.settings.rate,
|
|
701
|
+
pitch: item.pitch ?? voz.settings.pitch,
|
|
702
|
+
});
|
|
703
|
+
ap.audio.src = `/api/tts?${params}`;
|
|
704
|
+
ap.audio.play().catch(() => {
|
|
705
|
+
advanceAudio(item, new Error('el navegador bloqueó el audio: tocá la página y probá de nuevo'));
|
|
706
|
+
});
|
|
707
|
+
renderAudio();
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
export function advanceAudio(item, error) {
|
|
711
|
+
if (!item || ap.current !== item) return;
|
|
712
|
+
ap.current = null;
|
|
713
|
+
if (error) item.reject?.(error);
|
|
714
|
+
else item.resolve?.();
|
|
715
|
+
playNextAudio();
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
export function toggleAudio() {
|
|
719
|
+
if (!ap.current) return;
|
|
720
|
+
if (ap.audio.paused) ap.audio.play().catch(() => {});
|
|
721
|
+
else ap.audio.pause();
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** Detiene lo que suena y vacía lo que espera. */
|
|
725
|
+
export function stopAudio() {
|
|
726
|
+
for (const item of ap.queue) item.resolve?.();
|
|
727
|
+
ap.queue = [];
|
|
728
|
+
const current = ap.current;
|
|
729
|
+
ap.current = null;
|
|
730
|
+
current?.resolve?.();
|
|
731
|
+
ap.audio.pause();
|
|
732
|
+
ap.audio.removeAttribute('src');
|
|
733
|
+
ap.audio.load();
|
|
734
|
+
if (ap.barEl) ap.barEl.style.width = '0%';
|
|
735
|
+
renderAudio();
|
|
736
|
+
notifyAudioIdle();
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
export function renderAudio() {
|
|
740
|
+
if (!ap.el || !ap.ready) return;
|
|
741
|
+
const active = Boolean(ap.current) || ap.queue.length > 0;
|
|
742
|
+
ap.el.hidden = !active;
|
|
743
|
+
if (!active) return;
|
|
744
|
+
ap.labelEl.textContent = ap.current?.label ?? 'audio';
|
|
745
|
+
ap.queueEl.hidden = ap.queue.length === 0;
|
|
746
|
+
ap.queueEl.textContent = ap.queue.length ? `+${ap.queue.length}` : '';
|
|
747
|
+
const playing = Boolean(ap.current) && !ap.audio.paused;
|
|
748
|
+
ap.iconEl.replaceChildren(icon(playing ? 'pause' : 'play', 14));
|
|
749
|
+
ap.toggle.title = playing ? 'pausar' : 'reproducir';
|
|
750
|
+
ap.toggle.setAttribute('aria-label', ap.toggle.title);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** Reproduce una frase contra el endpoint de sintesis del server. */
|
|
754
|
+
export function reproducirVoz(frase, { voice, rate, pitch, label, maxAgeMs } = {}) {
|
|
755
|
+
return enqueueSpeech({ texto: frase, voice, rate, pitch, label, maxAgeMs });
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Narra una frase del turno. Si el turno tarda y la frase quedó vieja en la
|
|
760
|
+
* cola, se descarta al llegar su turno (`maxAgeMs`): narrar el pasado no informa.
|
|
761
|
+
*/
|
|
762
|
+
export function hablar(texto, { label = 'narrando', maxAgeMs = 45000, force = false } = {}) {
|
|
763
|
+
const frase = String(texto ?? '').trim();
|
|
764
|
+
if (!frase || (!voz.settings.enabled && !force)) return;
|
|
765
|
+
reproducirVoz(frase, { label, maxAgeMs }).catch(() => {});
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** Habla el progreso de un turno EN VIVO: el replay de una reconexion no habla. */
|
|
769
|
+
export function narrar(event, data, turn) {
|
|
770
|
+
if (!turn?.live) return;
|
|
771
|
+
const handsFreeOn = handsFree.active;
|
|
772
|
+
const { enabled, announce } = voz.settings;
|
|
773
|
+
if (!enabled && !handsFreeOn) return;
|
|
774
|
+
// Manos libres: conversacion, sin narracion de arranque/herramientas; lee la
|
|
775
|
+
// respuesta final (y el asesor) aunque la narracion este apagada en Ajustes.
|
|
776
|
+
if (handsFreeOn) {
|
|
777
|
+
if (event === 'advice') {
|
|
778
|
+
if (data?.say) hablar(data.say, { label: 'asesor', maxAgeMs: 0, force: true });
|
|
779
|
+
} else if (event === 'done') {
|
|
780
|
+
const spoken = speechText(data?.content);
|
|
781
|
+
if (spoken) hablar(spoken, { label: 'respuesta', maxAgeMs: 0, force: true });
|
|
782
|
+
else if (data?.say) hablar(data.say, { maxAgeMs: 0, force: true });
|
|
783
|
+
} else if ((event === 'aborted' || event === 'error') && data?.say) {
|
|
784
|
+
hablar(data.say, { maxAgeMs: 0, force: true });
|
|
785
|
+
}
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
if (!enabled) return;
|
|
789
|
+
if (event === 'start' && announce.start) hablar(data?.say);
|
|
790
|
+
else if (event === 'tool_call' && announce.tools) hablar(data?.say);
|
|
791
|
+
// El asesor y la respuesta final son el cierre del turno: si quedan en cola
|
|
792
|
+
// detras de un audio largo, igual hay que leerlos (sin vencimiento).
|
|
793
|
+
else if (event === 'advice') {
|
|
794
|
+
if (data?.kind === 'summary') {
|
|
795
|
+
if (announce.summary) hablar(data?.say, { label: 'resumen', maxAgeMs: 0 });
|
|
796
|
+
} else if (announce.advice) {
|
|
797
|
+
hablar(data?.say, { label: 'asesor', maxAgeMs: 0 });
|
|
798
|
+
}
|
|
799
|
+
} else if (event === 'done' && announce.answer) {
|
|
800
|
+
// Lee la respuesta final limpia; si era solo codigo, cae al cierre.
|
|
801
|
+
const spoken = speechText(data?.content);
|
|
802
|
+
if (spoken) hablar(spoken, { label: 'respuesta', maxAgeMs: 0 });
|
|
803
|
+
else if (announce.end) hablar(data?.say);
|
|
804
|
+
} else if ((event === 'done' || event === 'aborted' || event === 'error') && announce.end) hablar(data?.say);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
export async function cargarVoces() {
|
|
808
|
+
if (!vozVoiceEl || vozVoiceEl.options.length) return;
|
|
809
|
+
const data = await api('/api/tts/voices?filter=es-').catch(() => null);
|
|
810
|
+
if (!data?.voices?.length) return;
|
|
811
|
+
// Solo voces en español (el server ya filtra; guarda por si cambia).
|
|
812
|
+
const espanol = data.voices.filter((v) => /^es[-_]/i.test(String(v.Locale || v.ShortName || '')));
|
|
813
|
+
for (const v of espanol.sort((a, b) => String(a.ShortName).localeCompare(String(b.ShortName)))) {
|
|
814
|
+
const option = el('option', '', `${v.ShortName}${v.Gender === 'Female' ? ' · femenina' : v.Gender === 'Male' ? ' · masculina' : ''}`);
|
|
815
|
+
option.value = v.ShortName;
|
|
816
|
+
vozVoiceEl.appendChild(option);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
export async function aplicarVoz(data) {
|
|
821
|
+
if (!data) return;
|
|
822
|
+
voz.settings = {
|
|
823
|
+
enabled: data.enabled === true,
|
|
824
|
+
voice: data.voice || 'es-AR-ElenaNeural',
|
|
825
|
+
rate: data.rate || '+0%',
|
|
826
|
+
pitch: data.pitch || '+0Hz',
|
|
827
|
+
announce: {
|
|
828
|
+
start: data.announce?.start !== false,
|
|
829
|
+
tools: data.announce?.tools !== false,
|
|
830
|
+
end: data.announce?.end !== false,
|
|
831
|
+
advice: data.announce?.advice !== false,
|
|
832
|
+
answer: data.announce?.answer === true,
|
|
833
|
+
summary: data.announce?.summary === true,
|
|
834
|
+
},
|
|
835
|
+
};
|
|
836
|
+
if (vozEnabledEl) vozEnabledEl.checked = voz.settings.enabled;
|
|
837
|
+
if (vozFfmpegEl) vozFfmpegEl.hidden = data.ffmpeg !== false;
|
|
838
|
+
if (vozVoiceEl) {
|
|
839
|
+
await cargarVoces();
|
|
840
|
+
if (![...vozVoiceEl.options].some((o) => o.value === voz.settings.voice)) {
|
|
841
|
+
const option = el('option', '', voz.settings.voice);
|
|
842
|
+
option.value = voz.settings.voice;
|
|
843
|
+
vozVoiceEl.appendChild(option);
|
|
844
|
+
}
|
|
845
|
+
vozVoiceEl.value = voz.settings.voice;
|
|
846
|
+
}
|
|
847
|
+
if (vozRateEl) vozRateEl.value = rateToSlider(voz.settings.rate);
|
|
848
|
+
if (vozPitchEl) vozPitchEl.value = pitchToSlider(voz.settings.pitch);
|
|
849
|
+
renderVozSliders();
|
|
850
|
+
if (vozAnnStartEl) vozAnnStartEl.checked = voz.settings.announce.start;
|
|
851
|
+
if (vozAnnToolsEl) vozAnnToolsEl.checked = voz.settings.announce.tools;
|
|
852
|
+
if (vozAnnEndEl) vozAnnEndEl.checked = voz.settings.announce.end;
|
|
853
|
+
if (vozAnnAdviceEl) vozAnnAdviceEl.checked = voz.settings.announce.advice;
|
|
854
|
+
if (vozAnnSummaryEl) vozAnnSummaryEl.checked = voz.settings.announce.summary;
|
|
855
|
+
if (vozAnnAnswerEl) vozAnnAnswerEl.checked = voz.settings.announce.answer;
|
|
856
|
+
const estado = $('#voz-status');
|
|
857
|
+
if (estado) {
|
|
858
|
+
estado.className = `summary${voz.settings.enabled ? ' ok' : ''}`;
|
|
859
|
+
estado.textContent = voz.settings.enabled ? 'activa' : 'apagada';
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
export async function refreshVoz() {
|
|
864
|
+
if (!vozEnabledEl) return;
|
|
865
|
+
const data = await api('/api/tts/settings').catch(() => null);
|
|
866
|
+
if (data) aplicarVoz(data);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
export async function submitVoz() {
|
|
870
|
+
setStatus('#voz-msg', 'guardando…');
|
|
871
|
+
const body = {
|
|
872
|
+
enabled: vozEnabledEl?.checked === true,
|
|
873
|
+
voice: vozVoiceEl?.value,
|
|
874
|
+
rate: sliderToRate(vozRateEl?.value),
|
|
875
|
+
pitch: sliderToPitch(vozPitchEl?.value),
|
|
876
|
+
announce: {
|
|
877
|
+
start: vozAnnStartEl?.checked !== false,
|
|
878
|
+
tools: vozAnnToolsEl?.checked !== false,
|
|
879
|
+
end: vozAnnEndEl?.checked !== false,
|
|
880
|
+
advice: vozAnnAdviceEl?.checked !== false,
|
|
881
|
+
answer: vozAnnAnswerEl?.checked === true,
|
|
882
|
+
summary: vozAnnSummaryEl?.checked === true,
|
|
883
|
+
},
|
|
884
|
+
};
|
|
885
|
+
const data = await api('/api/tts/settings', { method: 'PUT', body }).catch((e) => ({ error: e.message }));
|
|
886
|
+
if (data?.error) return setStatus('#voz-msg', data.error, 'err');
|
|
887
|
+
await aplicarVoz(data);
|
|
888
|
+
setStatus('#voz-msg', 'guardado', 'ok');
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/* --- ajustes: transcripción ----------------------------------------------- */
|
|
892
|
+
|
|
893
|
+
export const trModeEl = $('#tr-mode');
|
|
894
|
+
export const trLanguageEl = $('#tr-language');
|
|
895
|
+
|
|
896
|
+
// Config efectiva del dictado; se carga al boot para que el micrófono del chat
|
|
897
|
+
// la use sin abrir Ajustes.
|
|
898
|
+
export const transcribeConfig = {
|
|
899
|
+
mode: 'server',
|
|
900
|
+
language: 'es-AR',
|
|
901
|
+
provider: '',
|
|
902
|
+
model: '',
|
|
903
|
+
fallback: { provider: '', model: '' },
|
|
904
|
+
};
|
|
905
|
+
|
|
906
|
+
export function syncTranscribeMode() {
|
|
907
|
+
const mode = trModeEl?.value ?? transcribeConfig.mode;
|
|
908
|
+
const serverFields = $('#tr-server-fields');
|
|
909
|
+
if (serverFields) serverFields.hidden = mode !== 'server';
|
|
910
|
+
const browserNote = $('#tr-browser-note');
|
|
911
|
+
if (browserNote) browserNote.hidden = mode !== 'browser';
|
|
912
|
+
const googleNote = $('#tr-google-note');
|
|
913
|
+
if (googleNote) googleNote.hidden = mode !== 'google';
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
export function aplicarTranscribe(data) {
|
|
917
|
+
if (!data) return;
|
|
918
|
+
transcribeConfig.mode = ['browser', 'google'].includes(data.mode) ? data.mode : 'server';
|
|
919
|
+
transcribeConfig.language = data.language || 'es-AR';
|
|
920
|
+
transcribeConfig.provider = data.provider ?? '';
|
|
921
|
+
transcribeConfig.model = data.model ?? '';
|
|
922
|
+
transcribeConfig.fallback = data.fallback ?? { provider: '', model: '' };
|
|
923
|
+
if (trModeEl && document.activeElement !== trModeEl) trModeEl.value = transcribeConfig.mode;
|
|
924
|
+
if (trLanguageEl && document.activeElement !== trLanguageEl) trLanguageEl.value = transcribeConfig.language;
|
|
925
|
+
if (trProviderEl && document.activeElement !== trProviderEl) trProviderEl.value = transcribeConfig.provider;
|
|
926
|
+
if (trModelEl && document.activeElement !== trModelEl) trModelEl.value = transcribeConfig.model;
|
|
927
|
+
if (trProviderEl) trProviderEl.placeholder = transcribeConfig.fallback.provider || 'groq · openai';
|
|
928
|
+
if (trModelEl) trModelEl.placeholder = transcribeConfig.fallback.model || 'whisper-large-v3-turbo';
|
|
929
|
+
syncTranscribeMode();
|
|
930
|
+
if (transcribeConfig.mode === 'browser') {
|
|
931
|
+
setStatus('#tr-status', `navegador · ${transcribeConfig.language}`, '');
|
|
932
|
+
} else if (transcribeConfig.mode === 'google') {
|
|
933
|
+
setStatus('#tr-status', `google (sin API key) · ${transcribeConfig.language}`, '');
|
|
934
|
+
} else {
|
|
935
|
+
const effProvider = transcribeConfig.provider || transcribeConfig.fallback.provider || 'proveedor activo';
|
|
936
|
+
const effModel = transcribeConfig.model || transcribeConfig.fallback.model || 'whisper-1';
|
|
937
|
+
setStatus('#tr-status', `usa: ${effProvider} · ${effModel}`, '');
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
export async function refreshTranscribe() {
|
|
942
|
+
if (!trModeEl) return;
|
|
943
|
+
const data = await api('/api/transcribe/settings').catch(() => null);
|
|
944
|
+
if (data) aplicarTranscribe(data);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
export async function submitTranscribe() {
|
|
948
|
+
setStatus('#tr-msg', 'guardando…');
|
|
949
|
+
const data = await api('/api/transcribe/settings', {
|
|
950
|
+
method: 'PUT',
|
|
951
|
+
body: {
|
|
952
|
+
mode: trModeEl?.value || 'server',
|
|
953
|
+
language: trLanguageEl?.value.trim() || 'es-AR',
|
|
954
|
+
provider: trProviderEl?.value.trim() || '',
|
|
955
|
+
model: trModelEl?.value.trim() || '',
|
|
956
|
+
},
|
|
957
|
+
}).catch((e) => ({ error: e.message }));
|
|
958
|
+
if (data?.error) return setStatus('#tr-msg', data.error, 'err');
|
|
959
|
+
setStatus('#tr-msg', 'guardado', 'ok');
|
|
960
|
+
aplicarTranscribe(data);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
export const trTestBtn = $('#tr-test');
|
|
964
|
+
export let trRecording = null;
|
|
965
|
+
export let browserTest = null;
|
|
966
|
+
|
|
967
|
+
export function startBrowserTest() {
|
|
968
|
+
let dictation;
|
|
969
|
+
try {
|
|
970
|
+
dictation = createBrowserDictation({
|
|
971
|
+
language: transcribeConfig.language,
|
|
972
|
+
onText: (_final, combined) => {
|
|
973
|
+
const text = combined.trim();
|
|
974
|
+
setStatus('#tr-test-result', text ? `“${text}”` : 'escuchando…', text ? 'ok' : '');
|
|
975
|
+
},
|
|
976
|
+
onEnd: () => {
|
|
977
|
+
if (browserTest === dictation) browserTest = null;
|
|
978
|
+
micVisual(trTestBtn);
|
|
979
|
+
},
|
|
980
|
+
onError: (error) => setStatus('#tr-test-result', error.message, 'err'),
|
|
981
|
+
});
|
|
982
|
+
} catch (error) {
|
|
983
|
+
return setStatus('#tr-test-result', error.message, 'err');
|
|
984
|
+
}
|
|
985
|
+
browserTest = dictation;
|
|
986
|
+
setStatus('#tr-test-result', '');
|
|
987
|
+
micVisual(trTestBtn, { live: true });
|
|
988
|
+
try {
|
|
989
|
+
dictation.start();
|
|
990
|
+
} catch (error) {
|
|
991
|
+
browserTest = null;
|
|
992
|
+
micVisual(trTestBtn);
|
|
993
|
+
setStatus('#tr-test-result', error.message, 'err');
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/** Graba con el micrófono y muestra acá qué devuelve la transcripción. */
|
|
998
|
+
export async function toggleTrTest() {
|
|
999
|
+
if (!trTestBtn) return;
|
|
1000
|
+
if (browserTest) {
|
|
1001
|
+
browserTest.stop();
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
if (trRecording) {
|
|
1005
|
+
trRecording.stop();
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
if (transcribeConfig.mode === 'browser') return startBrowserTest();
|
|
1009
|
+
|
|
1010
|
+
const rec = createRecording();
|
|
1011
|
+
trTestBtn.disabled = true;
|
|
1012
|
+
setStatus('#tr-test-result', '');
|
|
1013
|
+
try {
|
|
1014
|
+
await rec.start();
|
|
1015
|
+
} catch (error) {
|
|
1016
|
+
trTestBtn.disabled = false;
|
|
1017
|
+
micVisual(trTestBtn);
|
|
1018
|
+
return setStatus('#tr-test-result', `No pude acceder al micrófono: ${error.message}`, 'err');
|
|
1019
|
+
}
|
|
1020
|
+
trTestBtn.disabled = false;
|
|
1021
|
+
trRecording = rec;
|
|
1022
|
+
rec.done.then(async (blob) => {
|
|
1023
|
+
if (trRecording === rec) trRecording = null;
|
|
1024
|
+
if (!blob.size) {
|
|
1025
|
+
micVisual(trTestBtn);
|
|
1026
|
+
return setStatus('#tr-test-result', 'no se grabó audio', 'err');
|
|
1027
|
+
}
|
|
1028
|
+
micVisual(trTestBtn, { busy: true });
|
|
1029
|
+
setStatus('#tr-test-result', 'transcribiendo…');
|
|
1030
|
+
try {
|
|
1031
|
+
const text = await transcribirBlob(blob);
|
|
1032
|
+
setStatus('#tr-test-result', text ? `“${text}”` : 'no se entendió el audio', text ? 'ok' : 'err');
|
|
1033
|
+
} catch (error) {
|
|
1034
|
+
setStatus('#tr-test-result', error.message, 'err');
|
|
1035
|
+
} finally {
|
|
1036
|
+
micVisual(trTestBtn);
|
|
1037
|
+
}
|
|
1038
|
+
});
|
|
1039
|
+
await micCountdown(trTestBtn, () => rec.stopping);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
export async function testVoz() {
|
|
1043
|
+
setStatus('#voz-msg', 'sintetizando…');
|
|
1044
|
+
const trato = ($('#perfil-address')?.value || '').trim();
|
|
1045
|
+
const frase = trato
|
|
1046
|
+
? `${trato}, esta sería la voz predeterminada para mí`
|
|
1047
|
+
: 'Esta sería la voz predeterminada para mí';
|
|
1048
|
+
try {
|
|
1049
|
+
await reproducirVoz(frase, {
|
|
1050
|
+
voice: vozVoiceEl?.value,
|
|
1051
|
+
rate: sliderToRate(vozRateEl?.value),
|
|
1052
|
+
pitch: sliderToPitch(vozPitchEl?.value),
|
|
1053
|
+
});
|
|
1054
|
+
setStatus('#voz-msg', 'sonó', 'ok');
|
|
1055
|
+
} catch (error) {
|
|
1056
|
+
setStatus('#voz-msg', error.message, 'err');
|
|
1057
|
+
}
|
|
1058
|
+
}
|