botium-connector-voip 0.0.24 → 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 = [];
|
|
@@ -340,9 +510,9 @@ class BotiumConnectorVoip {
|
|
|
340
510
|
if (joinLogicHook && joinLogicHook.args && joinLogicHook.args.length > 0) {
|
|
341
511
|
silenceMs = parseInt(joinLogicHook.args[0], 10);
|
|
342
512
|
}
|
|
343
|
-
// Fallback to
|
|
513
|
+
// Fallback to effective timeout (subsequent if past first flush, else global)
|
|
344
514
|
if (!lodash__default["default"].isFinite(silenceMs) || silenceMs <= 0) {
|
|
345
|
-
silenceMs = parseInt(this.
|
|
515
|
+
silenceMs = parseInt(this._getEffectiveMessageHandlingTimeout(), 10);
|
|
346
516
|
}
|
|
347
517
|
// PSST: treat like JOIN, but subtract 500ms from timeout
|
|
348
518
|
if (lodash__default["default"].isFinite(silenceMs) && silenceMs > 0) {
|
|
@@ -364,9 +534,15 @@ class BotiumConnectorVoip {
|
|
|
364
534
|
this.silence = null;
|
|
365
535
|
this.msgCount = 0;
|
|
366
536
|
this.sttPartialCount = 0;
|
|
537
|
+
// Last interim transcript, used by `flushPendingBotMsgs` when no final arrives.
|
|
538
|
+
this.lastPartialBotMsg = null;
|
|
367
539
|
this.firstMsg = true;
|
|
368
540
|
this.firstSttInfoReceived = false;
|
|
369
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;
|
|
370
546
|
this.wsOpened = true;
|
|
371
547
|
debug$3(`Websocket connection to ${wsEndpoint} opened.`);
|
|
372
548
|
const sttHandling = this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING];
|
|
@@ -423,12 +599,45 @@ class BotiumConnectorVoip {
|
|
|
423
599
|
}
|
|
424
600
|
});
|
|
425
601
|
this.ws.on('message', async data => {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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.
|
|
429
638
|
if (this.stopCalled) {
|
|
430
|
-
const
|
|
431
|
-
if (!parsedData || !
|
|
639
|
+
const allowedPostStopTypes = ['fullRecord', 'fullRecordStart', 'fullRecordChunk', 'fullRecordEnd', 'error'];
|
|
640
|
+
if (!parsedData || !allowedPostStopTypes.includes(parsedData.type)) {
|
|
432
641
|
debug$3(`${this.sessionId} - Stop already called, ignoring incoming message`);
|
|
433
642
|
return;
|
|
434
643
|
}
|
|
@@ -537,6 +746,7 @@ class BotiumConnectorVoip {
|
|
|
537
746
|
connectDurationSec: parsedData.connectDuration || null,
|
|
538
747
|
sttPartialCount: this.sttPartialCount
|
|
539
748
|
});
|
|
749
|
+
flushPendingBotMsgs('callinfo_disconnected');
|
|
540
750
|
const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_STT_BODY));
|
|
541
751
|
if (parsedData.connectDuration && parsedData.connectDuration > 0) {
|
|
542
752
|
this.eventEmitter.emit('CONSUMPTION_METADATA', this, {
|
|
@@ -549,6 +759,7 @@ class BotiumConnectorVoip {
|
|
|
549
759
|
}
|
|
550
760
|
}
|
|
551
761
|
if (parsedData && parsedData.type === 'error') {
|
|
762
|
+
flushPendingBotMsgs('error');
|
|
552
763
|
this.end = true;
|
|
553
764
|
reject(new Error(`Error: ${parsedData.message}`));
|
|
554
765
|
sendBotMsg(new Error(`Error: ${parsedData.message}`));
|
|
@@ -573,11 +784,15 @@ class BotiumConnectorVoip {
|
|
|
573
784
|
source: 'fullRecordEnd',
|
|
574
785
|
base64Len
|
|
575
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');
|
|
576
790
|
this.end = true;
|
|
577
791
|
}
|
|
578
792
|
if (parsedData && parsedData.type === 'silence') {
|
|
579
793
|
if (lodash__default["default"].isNil(this._getIgnoreSilenceDurationAsserterLogicHook(this.convoStep))) {
|
|
580
794
|
if (lodash__default["default"].isNil(this._getJoinLogicHook(this.convoStep)) && parsedData.data.silence.length > 0) {
|
|
795
|
+
flushPendingBotMsgs('silence_exceeded');
|
|
581
796
|
this.end = true;
|
|
582
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`));
|
|
583
798
|
}
|
|
@@ -592,12 +807,69 @@ class BotiumConnectorVoip {
|
|
|
592
807
|
source: 'fullRecord',
|
|
593
808
|
base64Len: (base64 || '').length
|
|
594
809
|
});
|
|
810
|
+
flushPendingBotMsgs('fullRecord');
|
|
595
811
|
this.end = true;
|
|
596
812
|
}
|
|
597
813
|
if (parsedData && parsedData.data && parsedData.data.final === false) {
|
|
598
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).
|
|
599
846
|
if (this.silenceTimeout) {
|
|
600
|
-
|
|
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
|
+
}
|
|
601
873
|
}
|
|
602
874
|
if (lodash__default["default"].isNil(this._getIgnoreSilenceDurationAsserterLogicHook(this.convoStep))) {
|
|
603
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]) {
|
|
@@ -625,7 +897,7 @@ class BotiumConnectorVoip {
|
|
|
625
897
|
matched = true;
|
|
626
898
|
}
|
|
627
899
|
} else if (lodash__default["default"].isNil(joinLogicHook) && isJoinMethod) {
|
|
628
|
-
const joinTimeoutMs = toJoinTimeoutMs(this.
|
|
900
|
+
const joinTimeoutMs = toJoinTimeoutMs(this._getEffectiveMessageHandlingTimeout());
|
|
629
901
|
if (lodash__default["default"].isFinite(joinTimeoutMs) && joinTimeoutMs > 0 && silenceDuration > joinTimeoutMs / 1000) {
|
|
630
902
|
matched = true;
|
|
631
903
|
}
|
|
@@ -645,6 +917,8 @@ class BotiumConnectorVoip {
|
|
|
645
917
|
const msgText = parsedData.data.message || '';
|
|
646
918
|
const msgLen = typeof msgText === 'string' ? msgText.length : 0;
|
|
647
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;
|
|
648
922
|
debug$3(`Message: ${parsedData.data.message} / Confidence Score: ${this._getConfidenceScore(parsedData)} (Threshold: ${confidenceThreshold})`);
|
|
649
923
|
_info('stt_final', {
|
|
650
924
|
sessionId: this.sessionId,
|
|
@@ -722,27 +996,30 @@ class BotiumConnectorVoip {
|
|
|
722
996
|
this.prevData = parsedData;
|
|
723
997
|
if (successfulConfidenceScore) {
|
|
724
998
|
this.botMsgs.push(botMsg);
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
+
});
|
|
736
1017
|
}
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
this.joinLastPrevMsg = this.botMsgs[this.botMsgs.length - 1];
|
|
743
|
-
this.botMsgs = [];
|
|
744
|
-
}
|
|
745
|
-
}, joinTimeoutMs || 0);
|
|
1018
|
+
|
|
1019
|
+
// New final arrived — reset extension budget for next pause.
|
|
1020
|
+
this.psstRearmCount = 0;
|
|
1021
|
+
this.psstFirstRearmAt = null;
|
|
1022
|
+
armJoinSilenceTimer();
|
|
746
1023
|
}
|
|
747
1024
|
}
|
|
748
1025
|
}
|
|
@@ -795,6 +1072,22 @@ class BotiumConnectorVoip {
|
|
|
795
1072
|
const preferVoice = !!preferVoiceCapRaw;
|
|
796
1073
|
const skipTtsForMixedInput = preferVoice && hasText && hasVoiceMedia;
|
|
797
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;
|
|
798
1091
|
if (msg && msg.buttons && msg.buttons.length > 0) {
|
|
799
1092
|
const digits = sanitizeDtmfDigits(msg.buttons[0].payload);
|
|
800
1093
|
if (!digits) {
|
|
@@ -807,6 +1100,9 @@ class BotiumConnectorVoip {
|
|
|
807
1100
|
digits,
|
|
808
1101
|
sessionId: this.sessionId
|
|
809
1102
|
});
|
|
1103
|
+
stampAgentWire('dtmf', digits.length * DTMF_MS_PER_DIGIT, {
|
|
1104
|
+
digitCount: digits.length
|
|
1105
|
+
});
|
|
810
1106
|
this.ws.send(request);
|
|
811
1107
|
} else if (msg && msg.messageText) {
|
|
812
1108
|
// Check for DTMF tag in messageText: <DTMF>1234</DTMF>
|
|
@@ -828,6 +1124,9 @@ class BotiumConnectorVoip {
|
|
|
828
1124
|
digits,
|
|
829
1125
|
sessionId: this.sessionId
|
|
830
1126
|
});
|
|
1127
|
+
stampAgentWire('dtmf', digits.length * DTMF_MS_PER_DIGIT, {
|
|
1128
|
+
digitCount: digits.length
|
|
1129
|
+
});
|
|
831
1130
|
this.ws.send(request);
|
|
832
1131
|
return resolve();
|
|
833
1132
|
}
|
|
@@ -842,8 +1141,11 @@ class BotiumConnectorVoip {
|
|
|
842
1141
|
if (!ttsRequest) return reject(new Error('TTS not configured, only audio input supported'));
|
|
843
1142
|
msg.sourceData = ttsRequest;
|
|
844
1143
|
let ttsResult = null;
|
|
1144
|
+
const ttsStartedAt = Date.now();
|
|
1145
|
+
let ttsSynthMs = 0;
|
|
845
1146
|
try {
|
|
846
1147
|
ttsResult = await this._getTtsAudio(ttsRequest, msg.messageText);
|
|
1148
|
+
ttsSynthMs = Date.now() - ttsStartedAt;
|
|
847
1149
|
} catch (err) {
|
|
848
1150
|
return reject(new Error(`TTS "${msg.messageText}" failed - ${this._getAxiosErrOutput(err)}`));
|
|
849
1151
|
}
|
|
@@ -875,6 +1177,10 @@ class BotiumConnectorVoip {
|
|
|
875
1177
|
mimeType: 'audio/wav',
|
|
876
1178
|
base64: b64Buffer
|
|
877
1179
|
});
|
|
1180
|
+
stampAgentWire('tts', (duration || 0) * 1000, {
|
|
1181
|
+
ttsSynthMs,
|
|
1182
|
+
textLength: msg.messageText ? msg.messageText.length : 0
|
|
1183
|
+
});
|
|
878
1184
|
this.ws.send(request);
|
|
879
1185
|
} else {
|
|
880
1186
|
return reject(new Error('TTS failed, response is empty'));
|
|
@@ -896,6 +1202,10 @@ class BotiumConnectorVoip {
|
|
|
896
1202
|
const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>';
|
|
897
1203
|
debug$3(`Latency (bot says -> sendAudio/MEDIA): ${latencyMs} ms (last bot: ${botText})`);
|
|
898
1204
|
}
|
|
1205
|
+
// Stamp now; `requestedDurationMs` is backfilled once media metadata is parsed.
|
|
1206
|
+
stampAgentWire('media', 0, {
|
|
1207
|
+
mediaUri: msg.media[0].mediaUri || null
|
|
1208
|
+
});
|
|
899
1209
|
this.ws.send(request);
|
|
900
1210
|
msg.attachments.push({
|
|
901
1211
|
name: msg.media[0].mediaUri,
|
|
@@ -907,6 +1217,9 @@ class BotiumConnectorVoip {
|
|
|
907
1217
|
if (metadata && metadata.format && metadata.format.duration) {
|
|
908
1218
|
debug$3('Audio duration of user audio:', metadata.format.duration, 'seconds');
|
|
909
1219
|
duration = Math.round(metadata.format.duration);
|
|
1220
|
+
if (msg.voipAgent) {
|
|
1221
|
+
msg.voipAgent.requestedDurationMs = Math.max(0, Math.round((duration || 0) * 1000));
|
|
1222
|
+
}
|
|
910
1223
|
} else {
|
|
911
1224
|
reject(new Error('Could not determine audio duration from metadata'));
|
|
912
1225
|
}
|
|
@@ -914,7 +1227,11 @@ class BotiumConnectorVoip {
|
|
|
914
1227
|
reject(new Error(`Getting audio duration failed: ${err.message}`));
|
|
915
1228
|
}
|
|
916
1229
|
}
|
|
917
|
-
|
|
1230
|
+
const requestedDurationMs = Math.max(0, Math.round((duration || 0) * 1000));
|
|
1231
|
+
if (requestedDurationMs <= 0) {
|
|
1232
|
+
return resolve();
|
|
1233
|
+
}
|
|
1234
|
+
setTimeout(resolve, requestedDurationMs);
|
|
918
1235
|
}, 0);
|
|
919
1236
|
});
|
|
920
1237
|
}
|
|
@@ -1120,6 +1437,16 @@ class BotiumConnectorVoip {
|
|
|
1120
1437
|
_extractApiKey(body) {
|
|
1121
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;
|
|
1122
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
|
+
}
|
|
1123
1450
|
_getJoinLogicHook(convoStep) {
|
|
1124
1451
|
if (lodash__default["default"].isNil(convoStep)) return null;
|
|
1125
1452
|
if (lodash__default["default"].isNil(convoStep.logicHooks)) return null;
|