botium-connector-voip 0.0.23 → 0.0.25
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.
|
@@ -49,6 +49,7 @@ const Capabilities = {
|
|
|
49
49
|
VOIP_STT_TIMEOUT: 'VOIP_STT_TIMEOUT',
|
|
50
50
|
VOIP_STT_MESSAGE_HANDLING: 'VOIP_STT_MESSAGE_HANDLING',
|
|
51
51
|
VOIP_STT_MESSAGE_HANDLING_TIMEOUT: 'VOIP_STT_MESSAGE_HANDLING_TIMEOUT',
|
|
52
|
+
VOIP_STT_MESSAGE_HANDLING_TIMEOUT_SUBSEQUENT: 'VOIP_STT_MESSAGE_HANDLING_TIMEOUT_SUBSEQUENT',
|
|
52
53
|
VOIP_STT_MESSAGE_HANDLING_DELIMITER: 'VOIP_STT_MESSAGE_HANDLING_DELIMITER',
|
|
53
54
|
VOIP_STT_MESSAGE_HANDLING_PUNCTUATION: 'VOIP_STT_MESSAGE_HANDLING_PUNCTUATION',
|
|
54
55
|
VOIP_TTS_URL: 'VOIP_TTS_URL',
|
|
@@ -87,7 +88,8 @@ const Capabilities = {
|
|
|
87
88
|
VOIP_SILENCE_DURATION_TIMEOUT_START: 'VOIP_SILENCE_DURATION_TIMEOUT_START',
|
|
88
89
|
VOIP_STT_CONFIDENCE_THRESHOLD: 'VOIP_STT_CONFIDENCE_THRESHOLD',
|
|
89
90
|
VOIP_USE_GLOBAL_VOIP_WORKER: 'VOIP_USE_GLOBAL_VOIP_WORKER',
|
|
90
|
-
VOIP_USER_INPUT_PREFER_VOICE: 'VOIP_USER_INPUT_PREFER_VOICE'
|
|
91
|
+
VOIP_USER_INPUT_PREFER_VOICE: 'VOIP_USER_INPUT_PREFER_VOICE',
|
|
92
|
+
VOIP_EMIT_SPECULATIVE_TEXT: 'VOIP_EMIT_SPECULATIVE_TEXT'
|
|
91
93
|
};
|
|
92
94
|
const Defaults = {
|
|
93
95
|
VOIP_STT_METHOD: 'POST',
|
|
@@ -139,6 +141,7 @@ class BotiumConnectorVoip {
|
|
|
139
141
|
// For debugging latency between incoming STT (bot says) and outgoing audio (sendAudio)
|
|
140
142
|
this._lastBotSaysQueuedAt = null;
|
|
141
143
|
this._lastBotSaysText = null;
|
|
144
|
+
this._speculativeTurnToken = 0;
|
|
142
145
|
}
|
|
143
146
|
async Validate() {
|
|
144
147
|
debug$3('Validate called');
|
|
@@ -189,8 +192,20 @@ class BotiumConnectorVoip {
|
|
|
189
192
|
this.ttsCache.clear();
|
|
190
193
|
}
|
|
191
194
|
const sendBotMsg = botMsg => {
|
|
192
|
-
|
|
195
|
+
const queuedAt = Date.now();
|
|
196
|
+
this._lastBotSaysQueuedAt = queuedAt;
|
|
193
197
|
this._lastBotSaysText = botMsg && botMsg.messageText ? String(botMsg.messageText) : null;
|
|
198
|
+
// Stamp the wall-clock instant at which the connector released the bot
|
|
199
|
+
// utterance to botium-core's queue. Paired with `_receivedAtMs` (last
|
|
200
|
+
// STT final frame) this gives the true "join silence" the connector
|
|
201
|
+
// imposed, independent of how long the coach later takes to pick the
|
|
202
|
+
// message up with WaitBotSays().
|
|
203
|
+
if (botMsg && botMsg.sourceData) {
|
|
204
|
+
const head = Array.isArray(botMsg.sourceData) ? botMsg.sourceData[0] : botMsg.sourceData;
|
|
205
|
+
if (head && typeof head === 'object' && !('flushedAtMs' in head)) {
|
|
206
|
+
head.flushedAtMs = queuedAt;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
194
209
|
setTimeout(() => this.queueBotSays(botMsg), 0);
|
|
195
210
|
};
|
|
196
211
|
const joinBotMsg = (botMsgs, joinLastPrevMsg) => {
|
|
@@ -211,6 +226,161 @@ class BotiumConnectorVoip {
|
|
|
211
226
|
}
|
|
212
227
|
return botMsg;
|
|
213
228
|
};
|
|
229
|
+
|
|
230
|
+
// Returns true when a partial represents a NEW utterance rather than a tail/echo
|
|
231
|
+
// of buffered text. Used by PSST partial handler to extend the silence timer.
|
|
232
|
+
// Heuristic: substring check, then word-overlap fallback (<70% overlap = new).
|
|
233
|
+
const partialLooksLikeNewUtterance = (partialText, botMsgs) => {
|
|
234
|
+
const normalize = s => String(s || '').toLowerCase().replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
235
|
+
const partial = normalize(partialText);
|
|
236
|
+
if (!partial) return false;
|
|
237
|
+
const buffered = normalize((botMsgs || []).map(m => m && m.messageText || '').join(' '));
|
|
238
|
+
if (!buffered) return true;
|
|
239
|
+
if (buffered.includes(partial)) return false;
|
|
240
|
+
const partialWords = partial.split(' ').filter(Boolean);
|
|
241
|
+
if (partialWords.length === 0) return false;
|
|
242
|
+
const bufferedWords = new Set(buffered.split(' ').filter(Boolean));
|
|
243
|
+
const overlap = partialWords.filter(w => bufferedWords.has(w)).length;
|
|
244
|
+
return overlap / partialWords.length < 0.7;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
// Arm (or re-arm) the JOIN/PSST silence timer that flushes buffered STT
|
|
248
|
+
// chunks once the bot has been silent for `joinTimeoutMs`. No-op outside
|
|
249
|
+
// JOIN/PSST/CONCAT modes (other modes emit finals immediately).
|
|
250
|
+
const armJoinSilenceTimer = () => {
|
|
251
|
+
if (!this.botMsgs || this.botMsgs.length === 0) return;
|
|
252
|
+
const sttHandling = this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING];
|
|
253
|
+
const joinLogicHook = this._getJoinLogicHook(this.convoStep);
|
|
254
|
+
const isJoinMethod = sttHandling === 'JOIN' || sttHandling === 'PSST' || sttHandling === 'CONCAT' || !lodash__default["default"].isNil(joinLogicHook);
|
|
255
|
+
if (!isJoinMethod) return;
|
|
256
|
+
const isPsst = sttHandling === 'PSST';
|
|
257
|
+
const toJoinTimeoutMs = ms => {
|
|
258
|
+
const parsed = parseInt(ms, 10);
|
|
259
|
+
if (!lodash__default["default"].isFinite(parsed) || parsed <= 0) return null;
|
|
260
|
+
return isPsst ? Math.max(0, parsed - 500) : parsed;
|
|
261
|
+
};
|
|
262
|
+
let joinTimeoutMs = toJoinTimeoutMs(lodash__default["default"].get(joinLogicHook, 'args[0]'));
|
|
263
|
+
if (!lodash__default["default"].isFinite(joinTimeoutMs) || joinTimeoutMs <= 0) {
|
|
264
|
+
joinTimeoutMs = toJoinTimeoutMs(this._getEffectiveMessageHandlingTimeout());
|
|
265
|
+
}
|
|
266
|
+
if (this.silenceTimeout) {
|
|
267
|
+
clearTimeout(this.silenceTimeout);
|
|
268
|
+
this.silenceTimeout = null;
|
|
269
|
+
}
|
|
270
|
+
const bufferedAtArm = this.botMsgs.length;
|
|
271
|
+
const armedAt = Date.now();
|
|
272
|
+
_info('psst_timer_armed', {
|
|
273
|
+
sessionId: this.sessionId,
|
|
274
|
+
joinTimeoutMs: joinTimeoutMs || 0,
|
|
275
|
+
bufferedChunks: bufferedAtArm,
|
|
276
|
+
stopCalled: !!this.stopCalled
|
|
277
|
+
});
|
|
278
|
+
// Emit the authoritative join timeout so downstream consumers (e.g.
|
|
279
|
+
// SpeculationBuffer) can size their quiet threshold relative to it.
|
|
280
|
+
if (this.eventEmitter && lodash__default["default"].isFinite(joinTimeoutMs) && joinTimeoutMs > 0) {
|
|
281
|
+
try {
|
|
282
|
+
this.eventEmitter.emit('voip.psstTimerArmed', {
|
|
283
|
+
sessionId: this.sessionId,
|
|
284
|
+
joinTimeoutMs,
|
|
285
|
+
bufferedChunks: bufferedAtArm,
|
|
286
|
+
armedAt
|
|
287
|
+
});
|
|
288
|
+
} catch (emitErr) {
|
|
289
|
+
// Never block the silence timer on listener errors.
|
|
290
|
+
debug$3(`voip.psstTimerArmed emission failed: ${emitErr && emitErr.message}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
this.silenceTimeout = setTimeout(() => {
|
|
294
|
+
const fireDelay = Date.now() - armedAt;
|
|
295
|
+
if (this.botMsgs.length > 0) {
|
|
296
|
+
_info('psst_timer_fired', {
|
|
297
|
+
sessionId: this.sessionId,
|
|
298
|
+
bufferedChunks: this.botMsgs.length,
|
|
299
|
+
actualDelayMs: fireDelay,
|
|
300
|
+
scheduledDelayMs: joinTimeoutMs || 0,
|
|
301
|
+
outcome: 'emit'
|
|
302
|
+
});
|
|
303
|
+
debug$3('Silence Duration Timeout (JOIN/PSST):', joinTimeoutMs, 'ms');
|
|
304
|
+
sendBotMsg(joinBotMsg(this.botMsgs, this.joinLastPrevMsg));
|
|
305
|
+
this.firstMsg = false;
|
|
306
|
+
this.joinLastPrevMsg = this.botMsgs[this.botMsgs.length - 1];
|
|
307
|
+
this.botMsgs = [];
|
|
308
|
+
// Reset partial-driven extension budget for next cycle.
|
|
309
|
+
this.psstRearmCount = 0;
|
|
310
|
+
this.psstFirstRearmAt = null;
|
|
311
|
+
} else {
|
|
312
|
+
_info('psst_timer_fired', {
|
|
313
|
+
sessionId: this.sessionId,
|
|
314
|
+
bufferedChunks: 0,
|
|
315
|
+
actualDelayMs: fireDelay,
|
|
316
|
+
scheduledDelayMs: joinTimeoutMs || 0,
|
|
317
|
+
outcome: 'noop_empty_buffer'
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}, joinTimeoutMs || 0);
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
// Flush buffered STT chunks on teardown so a late final is not lost when
|
|
324
|
+
// the PSST silence timer is cleared by Stop(). Falls back to the cached
|
|
325
|
+
// interim transcript (`lastPartialBotMsg`) when STT never delivered a
|
|
326
|
+
// final for the closing utterance. Must be called from every terminal
|
|
327
|
+
// path before `this.end` is flipped.
|
|
328
|
+
const flushPendingBotMsgs = reason => {
|
|
329
|
+
if (this.silenceTimeout) {
|
|
330
|
+
_info('psst_timer_cleared', {
|
|
331
|
+
sessionId: this.sessionId,
|
|
332
|
+
reason: `flush:${reason}`,
|
|
333
|
+
bufferedChunks: this.botMsgs && this.botMsgs.length || 0
|
|
334
|
+
});
|
|
335
|
+
clearTimeout(this.silenceTimeout);
|
|
336
|
+
this.silenceTimeout = null;
|
|
337
|
+
}
|
|
338
|
+
if (this.botMsgs && this.botMsgs.length > 0) {
|
|
339
|
+
const chunkCount = this.botMsgs.length;
|
|
340
|
+
_info('stt_buffer_flushed_on_end', {
|
|
341
|
+
sessionId: this.sessionId,
|
|
342
|
+
reason,
|
|
343
|
+
outcome: 'flushed',
|
|
344
|
+
chunks: chunkCount
|
|
345
|
+
});
|
|
346
|
+
debug$3(`Flushing ${chunkCount} buffered STT chunk(s) on ${reason}`);
|
|
347
|
+
sendBotMsg(joinBotMsg(this.botMsgs, this.joinLastPrevMsg));
|
|
348
|
+
this.firstMsg = false;
|
|
349
|
+
this.joinLastPrevMsg = this.botMsgs[this.botMsgs.length - 1];
|
|
350
|
+
this.botMsgs = [];
|
|
351
|
+
this.lastPartialBotMsg = null;
|
|
352
|
+
this.psstRearmCount = 0;
|
|
353
|
+
this.psstFirstRearmAt = null;
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (this.lastPartialBotMsg && typeof this.lastPartialBotMsg.messageText === 'string' && this.lastPartialBotMsg.messageText.trim().length > 0) {
|
|
357
|
+
const recovered = this.lastPartialBotMsg;
|
|
358
|
+
const textLen = recovered.messageText.length;
|
|
359
|
+
const preview = recovered.messageText.trim().substring(0, 80);
|
|
360
|
+
_info('stt_partial_recovery_on_end', {
|
|
361
|
+
sessionId: this.sessionId,
|
|
362
|
+
reason,
|
|
363
|
+
outcome: 'partial_recovery',
|
|
364
|
+
messageLength: textLen,
|
|
365
|
+
message: preview
|
|
366
|
+
});
|
|
367
|
+
debug$3(`Recovering last STT partial on ${reason} (no final arrived): "${preview}"`);
|
|
368
|
+
sendBotMsg(recovered);
|
|
369
|
+
this.firstMsg = false;
|
|
370
|
+
this.joinLastPrevMsg = recovered;
|
|
371
|
+
this.lastPartialBotMsg = null;
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
_info('stt_flush_noop', {
|
|
375
|
+
sessionId: this.sessionId,
|
|
376
|
+
reason,
|
|
377
|
+
outcome: 'noop',
|
|
378
|
+
sttPartialCount: this.sttPartialCount || 0
|
|
379
|
+
});
|
|
380
|
+
debug$3(`flushPendingBotMsgs(${reason}): nothing buffered, no partial available (sttPartialCount=${this.sttPartialCount || 0})`);
|
|
381
|
+
};
|
|
382
|
+
// Expose on the instance so Stop() (outside this closure) can call it as a final safety net.
|
|
383
|
+
this._flushPendingBotMsgs = flushPendingBotMsgs;
|
|
214
384
|
const splitBotMsgs = botMsgs => {
|
|
215
385
|
const splitSentences = s => s.match(new RegExp(`[^${this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_PUNCTUATION]}]+[${this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_PUNCTUATION]}]+`, 'g'));
|
|
216
386
|
const botMsgsFinal = [];
|
|
@@ -236,21 +406,31 @@ class BotiumConnectorVoip {
|
|
|
236
406
|
let httpInitRetries = 0;
|
|
237
407
|
const connectHttp = async retryIndex => {
|
|
238
408
|
retryIndex = retryIndex || 0;
|
|
409
|
+
let initCallUrl = '';
|
|
239
410
|
try {
|
|
240
411
|
const workerUrl = this.caps[Capabilities.VOIP_USE_GLOBAL_VOIP_WORKER] ? process.env.BOTIUM_VOIP_WORKER_URL : this.caps[Capabilities.VOIP_WORKER_URL].replace('wss', 'https').replace('ws', 'http');
|
|
241
412
|
const baseUrl = workerUrl.endsWith('/') ? workerUrl.slice(0, -1) : workerUrl;
|
|
413
|
+
initCallUrl = `${baseUrl}/initCall`;
|
|
414
|
+
const postPayload = {
|
|
415
|
+
API_KEY: this.caps[Capabilities.VOIP_USE_GLOBAL_VOIP_WORKER] ? process.env.BOTIUM_VOIP_WORKER_APIKEY : this.caps[Capabilities.VOIP_WORKER_APIKEY]
|
|
416
|
+
};
|
|
417
|
+
const payloadForLog = {
|
|
418
|
+
...postPayload,
|
|
419
|
+
API_KEY: postPayload.API_KEY != null && postPayload.API_KEY !== '' ? '[REDACTED]' : postPayload.API_KEY
|
|
420
|
+
};
|
|
421
|
+
debug$3(`HTTP initCall: POST ${initCallUrl} payload=${JSON.stringify(payloadForLog)}`);
|
|
242
422
|
const res = await axios__default["default"]({
|
|
243
423
|
method: 'post',
|
|
244
|
-
data:
|
|
245
|
-
|
|
246
|
-
},
|
|
247
|
-
url: `${baseUrl}/initCall`
|
|
424
|
+
data: postPayload,
|
|
425
|
+
url: initCallUrl
|
|
248
426
|
});
|
|
249
427
|
if (res) {
|
|
250
428
|
data = res.data;
|
|
251
429
|
headers = res.headers;
|
|
430
|
+
debug$3(`HTTP initCall: response status=${res.status} data=${this._getAxiosShortenedOutput(res.data)}`);
|
|
252
431
|
}
|
|
253
432
|
} catch (err) {
|
|
433
|
+
debug$3(`HTTP initCall: failed ${initCallUrl} — ${this._getAxiosErrOutput(err)}`);
|
|
254
434
|
debug$3(`Retry ${retryIndex} / ${this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_MAXRETRIES]}: Connecting to VOIP Worker failed: ${err.message || 'Not reachable'}`);
|
|
255
435
|
if (retryIndex === this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_MAXRETRIES]) {
|
|
256
436
|
throw new Error(`Connecting to VOIP Worker failed: ${err.message || 'Not reachable'}`);
|
|
@@ -275,7 +455,15 @@ class BotiumConnectorVoip {
|
|
|
275
455
|
const connectWs = retryIndex => {
|
|
276
456
|
retryIndex = retryIndex || 0;
|
|
277
457
|
return new Promise((resolve, reject) => {
|
|
278
|
-
|
|
458
|
+
const useCookie = headers && 'set-cookie' in headers;
|
|
459
|
+
const connectPayload = useCookie ? {
|
|
460
|
+
withCookie: true,
|
|
461
|
+
cookieSize: headers['set-cookie'] && String(headers['set-cookie']).length || 0
|
|
462
|
+
} : {
|
|
463
|
+
withCookie: false
|
|
464
|
+
};
|
|
465
|
+
debug$3(`WebSocket connect: url=${wsEndpoint} attempt=${retryIndex + 1} max=${this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_MAXRETRIES] + 1} ` + `options=${JSON.stringify(connectPayload)}`);
|
|
466
|
+
if (useCookie) {
|
|
279
467
|
this.ws = new ws__default["default"](wsEndpoint, {
|
|
280
468
|
headers: {
|
|
281
469
|
Cookie: headers['set-cookie']
|
|
@@ -285,9 +473,11 @@ class BotiumConnectorVoip {
|
|
|
285
473
|
this.ws = new ws__default["default"](wsEndpoint);
|
|
286
474
|
}
|
|
287
475
|
this.ws.on('open', () => {
|
|
476
|
+
debug$3(`WebSocket connect: result=open url=${wsEndpoint} attempt=${retryIndex + 1} ` + `readyState=${this.ws && this.ws.readyState}`);
|
|
288
477
|
resolve(retryIndex);
|
|
289
478
|
});
|
|
290
|
-
this.ws.on('error',
|
|
479
|
+
this.ws.on('error', err => {
|
|
480
|
+
debug$3(`WebSocket connect: result=error url=${wsEndpoint} attempt=${retryIndex + 1} ` + `err=${err && err.message || err || 'unknown'}`);
|
|
291
481
|
if (retryIndex === this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_MAXRETRIES]) {
|
|
292
482
|
reject(new Error(`Websocket connection failed to ${wsEndpoint}`));
|
|
293
483
|
}
|
|
@@ -320,9 +510,9 @@ class BotiumConnectorVoip {
|
|
|
320
510
|
if (joinLogicHook && joinLogicHook.args && joinLogicHook.args.length > 0) {
|
|
321
511
|
silenceMs = parseInt(joinLogicHook.args[0], 10);
|
|
322
512
|
}
|
|
323
|
-
// Fallback to
|
|
513
|
+
// Fallback to effective timeout (subsequent if past first flush, else global)
|
|
324
514
|
if (!lodash__default["default"].isFinite(silenceMs) || silenceMs <= 0) {
|
|
325
|
-
silenceMs = parseInt(this.
|
|
515
|
+
silenceMs = parseInt(this._getEffectiveMessageHandlingTimeout(), 10);
|
|
326
516
|
}
|
|
327
517
|
// PSST: treat like JOIN, but subtract 500ms from timeout
|
|
328
518
|
if (lodash__default["default"].isFinite(silenceMs) && silenceMs > 0) {
|
|
@@ -344,9 +534,15 @@ class BotiumConnectorVoip {
|
|
|
344
534
|
this.silence = null;
|
|
345
535
|
this.msgCount = 0;
|
|
346
536
|
this.sttPartialCount = 0;
|
|
537
|
+
// Last interim transcript, used by `flushPendingBotMsgs` when no final arrives.
|
|
538
|
+
this.lastPartialBotMsg = null;
|
|
347
539
|
this.firstMsg = true;
|
|
348
540
|
this.firstSttInfoReceived = false;
|
|
349
541
|
this.silenceTimeout = null;
|
|
542
|
+
// PSST re-arm tracking: new-utterance partials reset the silence timer,
|
|
543
|
+
// bounded by MAX_EXTENSION_MS to prevent infinite stranding.
|
|
544
|
+
this.psstRearmCount = 0;
|
|
545
|
+
this.psstFirstRearmAt = null;
|
|
350
546
|
this.wsOpened = true;
|
|
351
547
|
debug$3(`Websocket connection to ${wsEndpoint} opened.`);
|
|
352
548
|
const sttHandling = this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING];
|
|
@@ -403,12 +599,45 @@ class BotiumConnectorVoip {
|
|
|
403
599
|
}
|
|
404
600
|
});
|
|
405
601
|
this.ws.on('message', async data => {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
602
|
+
// Drop non-JSON gateway frames (rare, but crash the process without this guard).
|
|
603
|
+
let parsedData;
|
|
604
|
+
try {
|
|
605
|
+
parsedData = JSON.parse(data);
|
|
606
|
+
// Earliest local observation of the frame. Consumed downstream
|
|
607
|
+
// as the "bot finished speaking" anchor for `recordingEpochMs`
|
|
608
|
+
// (propagates via `sourceData = parsedData` reference).
|
|
609
|
+
parsedData._receivedAtMs = Date.now();
|
|
610
|
+
} catch (parseErr) {
|
|
611
|
+
const rawString = (() => {
|
|
612
|
+
try {
|
|
613
|
+
if (Buffer.isBuffer(data)) return data.toString('utf8');
|
|
614
|
+
if (typeof data === 'string') return data;
|
|
615
|
+
if (data && typeof data.toString === 'function') return data.toString();
|
|
616
|
+
return '<non-stringable>';
|
|
617
|
+
} catch (_) {
|
|
618
|
+
return '<unreadable>';
|
|
619
|
+
}
|
|
620
|
+
})();
|
|
621
|
+
const preview = rawString.length > 200 ? `${rawString.substring(0, 200)}...` : rawString;
|
|
622
|
+
debug$3(`${this.sessionId} - Ignoring non-JSON WS frame from gateway: ${preview}`);
|
|
623
|
+
if (typeof _info === 'function') {
|
|
624
|
+
try {
|
|
625
|
+
_info('ws_non_json_frame', {
|
|
626
|
+
sessionId: this.sessionId,
|
|
627
|
+
preview,
|
|
628
|
+
byteLength: rawString.length,
|
|
629
|
+
parseError: parseErr && parseErr.message ? parseErr.message : String(parseErr)
|
|
630
|
+
});
|
|
631
|
+
} catch (_infoErr) {/* structured logger is best-effort */}
|
|
632
|
+
}
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
// After Stop(), still accept late-arriving recording frames
|
|
636
|
+
// (fullRecord*) and hard errors so `full_record.wav` is delivered
|
|
637
|
+
// on early-completion hangups. Post-Stop STT frames remain blocked.
|
|
409
638
|
if (this.stopCalled) {
|
|
410
|
-
const
|
|
411
|
-
if (!parsedData || !
|
|
639
|
+
const allowedPostStopTypes = ['fullRecord', 'fullRecordStart', 'fullRecordChunk', 'fullRecordEnd', 'error'];
|
|
640
|
+
if (!parsedData || !allowedPostStopTypes.includes(parsedData.type)) {
|
|
412
641
|
debug$3(`${this.sessionId} - Stop already called, ignoring incoming message`);
|
|
413
642
|
return;
|
|
414
643
|
}
|
|
@@ -517,6 +746,7 @@ class BotiumConnectorVoip {
|
|
|
517
746
|
connectDurationSec: parsedData.connectDuration || null,
|
|
518
747
|
sttPartialCount: this.sttPartialCount
|
|
519
748
|
});
|
|
749
|
+
flushPendingBotMsgs('callinfo_disconnected');
|
|
520
750
|
const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_STT_BODY));
|
|
521
751
|
if (parsedData.connectDuration && parsedData.connectDuration > 0) {
|
|
522
752
|
this.eventEmitter.emit('CONSUMPTION_METADATA', this, {
|
|
@@ -529,6 +759,7 @@ class BotiumConnectorVoip {
|
|
|
529
759
|
}
|
|
530
760
|
}
|
|
531
761
|
if (parsedData && parsedData.type === 'error') {
|
|
762
|
+
flushPendingBotMsgs('error');
|
|
532
763
|
this.end = true;
|
|
533
764
|
reject(new Error(`Error: ${parsedData.message}`));
|
|
534
765
|
sendBotMsg(new Error(`Error: ${parsedData.message}`));
|
|
@@ -553,11 +784,15 @@ class BotiumConnectorVoip {
|
|
|
553
784
|
source: 'fullRecordEnd',
|
|
554
785
|
base64Len
|
|
555
786
|
});
|
|
787
|
+
// Flush before `this.end = true` so the buffered final STT is not
|
|
788
|
+
// dropped when Stop() clears the PSST silence timer on teardown.
|
|
789
|
+
flushPendingBotMsgs('fullRecordEnd');
|
|
556
790
|
this.end = true;
|
|
557
791
|
}
|
|
558
792
|
if (parsedData && parsedData.type === 'silence') {
|
|
559
793
|
if (lodash__default["default"].isNil(this._getIgnoreSilenceDurationAsserterLogicHook(this.convoStep))) {
|
|
560
794
|
if (lodash__default["default"].isNil(this._getJoinLogicHook(this.convoStep)) && parsedData.data.silence.length > 0) {
|
|
795
|
+
flushPendingBotMsgs('silence_exceeded');
|
|
561
796
|
this.end = true;
|
|
562
797
|
sendBotMsg(new Error(`Silence Duration of ${parsedData.data.silence[0][2].toFixed(2)}s exceeded General Silence Duration Timeout of ${this.caps[Capabilities.VOIP_SILENCE_DURATION_TIMEOUT] / 1000}s`));
|
|
563
798
|
}
|
|
@@ -572,12 +807,69 @@ class BotiumConnectorVoip {
|
|
|
572
807
|
source: 'fullRecord',
|
|
573
808
|
base64Len: (base64 || '').length
|
|
574
809
|
});
|
|
810
|
+
flushPendingBotMsgs('fullRecord');
|
|
575
811
|
this.end = true;
|
|
576
812
|
}
|
|
577
813
|
if (parsedData && parsedData.data && parsedData.data.final === false) {
|
|
578
814
|
this.sttPartialCount++;
|
|
815
|
+
const partialText = parsedData.data.message;
|
|
816
|
+
if (typeof partialText === 'string' && partialText.trim().length > 0) {
|
|
817
|
+
this.lastPartialBotMsg = {
|
|
818
|
+
messageText: partialText,
|
|
819
|
+
sourceData: Object.assign({}, parsedData, {
|
|
820
|
+
partialRecovery: true
|
|
821
|
+
})
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
const partialPreview = typeof partialText === 'string' ? partialText.trim().substring(0, 60) : '';
|
|
825
|
+
_info('stt_partial_received', {
|
|
826
|
+
sessionId: this.sessionId,
|
|
827
|
+
partialIndex: this.sttPartialCount,
|
|
828
|
+
preview: partialPreview,
|
|
829
|
+
bufferedChunks: this.botMsgs && this.botMsgs.length || 0,
|
|
830
|
+
timerActive: !!this.silenceTimeout
|
|
831
|
+
});
|
|
832
|
+
// Emit liveness keep-alive so waiters can distinguish "bot silent"
|
|
833
|
+
// from "bot streaming partials between finals".
|
|
834
|
+
if (this.caps[Capabilities.VOIP_EMIT_SPECULATIVE_TEXT] && this.eventEmitter && typeof partialText === 'string' && partialText.trim().length > 0) {
|
|
835
|
+
this.eventEmitter.emit('voip.botActivity', {
|
|
836
|
+
sessionId: this.sessionId,
|
|
837
|
+
kind: 'partial',
|
|
838
|
+
partialIndex: this.sttPartialCount,
|
|
839
|
+
bufferedChunks: this.botMsgs && this.botMsgs.length || 0
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
// PSST partial timer logic:
|
|
843
|
+
// Empty buffer → clear timer (final will arm fresh).
|
|
844
|
+
// Buffered finals + new-utterance partial → re-arm within cap.
|
|
845
|
+
// Tail/echo or cap-exceeded partials → ignore (legacy path).
|
|
579
846
|
if (this.silenceTimeout) {
|
|
580
|
-
|
|
847
|
+
if (!this.botMsgs || this.botMsgs.length === 0) {
|
|
848
|
+
clearTimeout(this.silenceTimeout);
|
|
849
|
+
this.silenceTimeout = null;
|
|
850
|
+
} else {
|
|
851
|
+
const sttHandling = this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING];
|
|
852
|
+
const isPsstMode = sttHandling === 'PSST';
|
|
853
|
+
if (isPsstMode && typeof partialText === 'string' && partialText.trim().length > 0) {
|
|
854
|
+
const MAX_EXTENSION_MS = 60000;
|
|
855
|
+
const now = Date.now();
|
|
856
|
+
const withinCap = this.psstFirstRearmAt == null || now - this.psstFirstRearmAt < MAX_EXTENSION_MS;
|
|
857
|
+
if (withinCap && partialLooksLikeNewUtterance(partialText, this.botMsgs)) {
|
|
858
|
+
if (this.psstFirstRearmAt == null) this.psstFirstRearmAt = now;
|
|
859
|
+
this.psstRearmCount++;
|
|
860
|
+
_info('psst_timer_extended', {
|
|
861
|
+
sessionId: this.sessionId,
|
|
862
|
+
rearmCount: this.psstRearmCount,
|
|
863
|
+
msSinceFirstRearm: now - this.psstFirstRearmAt,
|
|
864
|
+
bufferedChunks: this.botMsgs.length,
|
|
865
|
+
reason: 'new_utterance_partial',
|
|
866
|
+
partialPreview
|
|
867
|
+
});
|
|
868
|
+
// armJoinSilenceTimer() handles clearing the existing timeout.
|
|
869
|
+
armJoinSilenceTimer();
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
581
873
|
}
|
|
582
874
|
if (lodash__default["default"].isNil(this._getIgnoreSilenceDurationAsserterLogicHook(this.convoStep))) {
|
|
583
875
|
if (!this.firstSttInfoReceived && lodash__default["default"].isNil(this._getJoinLogicHook(this.convoStep)) && this.caps[Capabilities.VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE] && parsedData.data.start && parsedData.data.start * 1000 > this.caps[Capabilities.VOIP_SILENCE_DURATION_TIMEOUT_START]) {
|
|
@@ -605,7 +897,7 @@ class BotiumConnectorVoip {
|
|
|
605
897
|
matched = true;
|
|
606
898
|
}
|
|
607
899
|
} else if (lodash__default["default"].isNil(joinLogicHook) && isJoinMethod) {
|
|
608
|
-
const joinTimeoutMs = toJoinTimeoutMs(this.
|
|
900
|
+
const joinTimeoutMs = toJoinTimeoutMs(this._getEffectiveMessageHandlingTimeout());
|
|
609
901
|
if (lodash__default["default"].isFinite(joinTimeoutMs) && joinTimeoutMs > 0 && silenceDuration > joinTimeoutMs / 1000) {
|
|
610
902
|
matched = true;
|
|
611
903
|
}
|
|
@@ -625,6 +917,8 @@ class BotiumConnectorVoip {
|
|
|
625
917
|
const msgText = parsedData.data.message || '';
|
|
626
918
|
const msgLen = typeof msgText === 'string' ? msgText.length : 0;
|
|
627
919
|
const msgPreview = typeof msgText === 'string' ? msgText.trim().substring(0, 80) : '';
|
|
920
|
+
// A final supersedes the cached interim; clear to avoid duplicate tail emission.
|
|
921
|
+
this.lastPartialBotMsg = null;
|
|
628
922
|
debug$3(`Message: ${parsedData.data.message} / Confidence Score: ${this._getConfidenceScore(parsedData)} (Threshold: ${confidenceThreshold})`);
|
|
629
923
|
_info('stt_final', {
|
|
630
924
|
sessionId: this.sessionId,
|
|
@@ -702,27 +996,30 @@ class BotiumConnectorVoip {
|
|
|
702
996
|
this.prevData = parsedData;
|
|
703
997
|
if (successfulConfidenceScore) {
|
|
704
998
|
this.botMsgs.push(botMsg);
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
999
|
+
if (this.caps[Capabilities.VOIP_EMIT_SPECULATIVE_TEXT] && this.eventEmitter) {
|
|
1000
|
+
const tentative = joinBotMsg(this.botMsgs, this.joinLastPrevMsg);
|
|
1001
|
+
this._speculativeTurnToken++;
|
|
1002
|
+
// speech{Start,End}Sec are audio-clock offsets (seconds since
|
|
1003
|
+
// call connect) taken from the STT worker, so downstream
|
|
1004
|
+
// consumers can align with the recording playhead directly.
|
|
1005
|
+
const firstChunkStart = lodash__default["default"].get(this.botMsgs, '[0].sourceData.data.start', null);
|
|
1006
|
+
const thisFrameStart = lodash__default["default"].get(parsedData, 'data.start', null);
|
|
1007
|
+
const thisFrameEnd = lodash__default["default"].get(parsedData, 'data.end', null);
|
|
1008
|
+
const speechStartSec = lodash__default["default"].isFinite(firstChunkStart) ? firstChunkStart : lodash__default["default"].isFinite(thisFrameStart) ? thisFrameStart : null;
|
|
1009
|
+
const speechEndSec = lodash__default["default"].isFinite(thisFrameEnd) ? thisFrameEnd : null;
|
|
1010
|
+
this.eventEmitter.emit('voip.speculativeBotText', {
|
|
1011
|
+
sessionId: this.sessionId,
|
|
1012
|
+
messageText: tentative.messageText,
|
|
1013
|
+
turnToken: this._speculativeTurnToken,
|
|
1014
|
+
speechStartSec,
|
|
1015
|
+
speechEndSec
|
|
1016
|
+
});
|
|
716
1017
|
}
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
this.joinLastPrevMsg = this.botMsgs[this.botMsgs.length - 1];
|
|
723
|
-
this.botMsgs = [];
|
|
724
|
-
}
|
|
725
|
-
}, joinTimeoutMs || 0);
|
|
1018
|
+
|
|
1019
|
+
// New final arrived — reset extension budget for next pause.
|
|
1020
|
+
this.psstRearmCount = 0;
|
|
1021
|
+
this.psstFirstRearmAt = null;
|
|
1022
|
+
armJoinSilenceTimer();
|
|
726
1023
|
}
|
|
727
1024
|
}
|
|
728
1025
|
}
|
|
@@ -775,6 +1072,22 @@ class BotiumConnectorVoip {
|
|
|
775
1072
|
const preferVoice = !!preferVoiceCapRaw;
|
|
776
1073
|
const skipTtsForMixedInput = preferVoice && hasText && hasVoiceMedia;
|
|
777
1074
|
debug$3(`UserSays routing: hasText=${hasText} hasVoiceMedia=${hasVoiceMedia} preferVoice=${preferVoice} preferVoiceRaw=${JSON.stringify(preferVoiceCapRaw)} skipTtsForMixedInput=${skipTtsForMixedInput}`);
|
|
1075
|
+
|
|
1076
|
+
// Stamp `msg.voipAgent` at the moment bytes leave the WebSocket so
|
|
1077
|
+
// the coach can place the agent turn on the recording timeline.
|
|
1078
|
+
// `requestedDurationMs` is the best estimate of on-wire playback
|
|
1079
|
+
// length (DTMF tones × digits, TTS synth output, parsed media duration).
|
|
1080
|
+
const stampAgentWire = (wireKind, requestedDurationMs, extras = {}) => {
|
|
1081
|
+
msg.voipAgent = {
|
|
1082
|
+
wireSentAtMs: Date.now(),
|
|
1083
|
+
inputType,
|
|
1084
|
+
wireKind,
|
|
1085
|
+
requestedDurationMs: Math.max(0, Math.round(requestedDurationMs || 0)),
|
|
1086
|
+
...extras
|
|
1087
|
+
};
|
|
1088
|
+
};
|
|
1089
|
+
// Twilio default: 100 ms tone + 100 ms gap per digit. Drives agent-bar width only.
|
|
1090
|
+
const DTMF_MS_PER_DIGIT = 200;
|
|
778
1091
|
if (msg && msg.buttons && msg.buttons.length > 0) {
|
|
779
1092
|
const digits = sanitizeDtmfDigits(msg.buttons[0].payload);
|
|
780
1093
|
if (!digits) {
|
|
@@ -787,6 +1100,9 @@ class BotiumConnectorVoip {
|
|
|
787
1100
|
digits,
|
|
788
1101
|
sessionId: this.sessionId
|
|
789
1102
|
});
|
|
1103
|
+
stampAgentWire('dtmf', digits.length * DTMF_MS_PER_DIGIT, {
|
|
1104
|
+
digitCount: digits.length
|
|
1105
|
+
});
|
|
790
1106
|
this.ws.send(request);
|
|
791
1107
|
} else if (msg && msg.messageText) {
|
|
792
1108
|
// Check for DTMF tag in messageText: <DTMF>1234</DTMF>
|
|
@@ -808,6 +1124,9 @@ class BotiumConnectorVoip {
|
|
|
808
1124
|
digits,
|
|
809
1125
|
sessionId: this.sessionId
|
|
810
1126
|
});
|
|
1127
|
+
stampAgentWire('dtmf', digits.length * DTMF_MS_PER_DIGIT, {
|
|
1128
|
+
digitCount: digits.length
|
|
1129
|
+
});
|
|
811
1130
|
this.ws.send(request);
|
|
812
1131
|
return resolve();
|
|
813
1132
|
}
|
|
@@ -822,8 +1141,11 @@ class BotiumConnectorVoip {
|
|
|
822
1141
|
if (!ttsRequest) return reject(new Error('TTS not configured, only audio input supported'));
|
|
823
1142
|
msg.sourceData = ttsRequest;
|
|
824
1143
|
let ttsResult = null;
|
|
1144
|
+
const ttsStartedAt = Date.now();
|
|
1145
|
+
let ttsSynthMs = 0;
|
|
825
1146
|
try {
|
|
826
1147
|
ttsResult = await this._getTtsAudio(ttsRequest, msg.messageText);
|
|
1148
|
+
ttsSynthMs = Date.now() - ttsStartedAt;
|
|
827
1149
|
} catch (err) {
|
|
828
1150
|
return reject(new Error(`TTS "${msg.messageText}" failed - ${this._getAxiosErrOutput(err)}`));
|
|
829
1151
|
}
|
|
@@ -855,6 +1177,10 @@ class BotiumConnectorVoip {
|
|
|
855
1177
|
mimeType: 'audio/wav',
|
|
856
1178
|
base64: b64Buffer
|
|
857
1179
|
});
|
|
1180
|
+
stampAgentWire('tts', (duration || 0) * 1000, {
|
|
1181
|
+
ttsSynthMs,
|
|
1182
|
+
textLength: msg.messageText ? msg.messageText.length : 0
|
|
1183
|
+
});
|
|
858
1184
|
this.ws.send(request);
|
|
859
1185
|
} else {
|
|
860
1186
|
return reject(new Error('TTS failed, response is empty'));
|
|
@@ -876,6 +1202,10 @@ class BotiumConnectorVoip {
|
|
|
876
1202
|
const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>';
|
|
877
1203
|
debug$3(`Latency (bot says -> sendAudio/MEDIA): ${latencyMs} ms (last bot: ${botText})`);
|
|
878
1204
|
}
|
|
1205
|
+
// Stamp now; `requestedDurationMs` is backfilled once media metadata is parsed.
|
|
1206
|
+
stampAgentWire('media', 0, {
|
|
1207
|
+
mediaUri: msg.media[0].mediaUri || null
|
|
1208
|
+
});
|
|
879
1209
|
this.ws.send(request);
|
|
880
1210
|
msg.attachments.push({
|
|
881
1211
|
name: msg.media[0].mediaUri,
|
|
@@ -887,6 +1217,9 @@ class BotiumConnectorVoip {
|
|
|
887
1217
|
if (metadata && metadata.format && metadata.format.duration) {
|
|
888
1218
|
debug$3('Audio duration of user audio:', metadata.format.duration, 'seconds');
|
|
889
1219
|
duration = Math.round(metadata.format.duration);
|
|
1220
|
+
if (msg.voipAgent) {
|
|
1221
|
+
msg.voipAgent.requestedDurationMs = Math.max(0, Math.round((duration || 0) * 1000));
|
|
1222
|
+
}
|
|
890
1223
|
} else {
|
|
891
1224
|
reject(new Error('Could not determine audio duration from metadata'));
|
|
892
1225
|
}
|
|
@@ -894,7 +1227,11 @@ class BotiumConnectorVoip {
|
|
|
894
1227
|
reject(new Error(`Getting audio duration failed: ${err.message}`));
|
|
895
1228
|
}
|
|
896
1229
|
}
|
|
897
|
-
|
|
1230
|
+
const requestedDurationMs = Math.max(0, Math.round((duration || 0) * 1000));
|
|
1231
|
+
if (requestedDurationMs <= 0) {
|
|
1232
|
+
return resolve();
|
|
1233
|
+
}
|
|
1234
|
+
setTimeout(resolve, requestedDurationMs);
|
|
898
1235
|
}, 0);
|
|
899
1236
|
});
|
|
900
1237
|
}
|
|
@@ -1100,6 +1437,16 @@ class BotiumConnectorVoip {
|
|
|
1100
1437
|
_extractApiKey(body) {
|
|
1101
1438
|
return lodash__default["default"].get(body, 'polly.credentials.accessKeyId') || lodash__default["default"].get(body, 'google.credentials.client_email') || lodash__default["default"].get(body, 'ibm.credentials.apikey') || lodash__default["default"].get(body, 'awstranscribe.credentials.accessKeyId') || lodash__default["default"].get(body, 'azure.credentials.subscriptionKey') || null;
|
|
1102
1439
|
}
|
|
1440
|
+
|
|
1441
|
+
// After the first flush, use the shorter SUBSEQUENT timeout if configured.
|
|
1442
|
+
// Falls back to the global timeout — fully backward-compatible.
|
|
1443
|
+
_getEffectiveMessageHandlingTimeout() {
|
|
1444
|
+
if (!this.firstMsg) {
|
|
1445
|
+
const sub = parseInt(this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_TIMEOUT_SUBSEQUENT], 10);
|
|
1446
|
+
if (lodash__default["default"].isFinite(sub) && sub > 0) return sub;
|
|
1447
|
+
}
|
|
1448
|
+
return this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_TIMEOUT];
|
|
1449
|
+
}
|
|
1103
1450
|
_getJoinLogicHook(convoStep) {
|
|
1104
1451
|
if (lodash__default["default"].isNil(convoStep)) return null;
|
|
1105
1452
|
if (lodash__default["default"].isNil(convoStep.logicHooks)) return null;
|