botium-connector-voip 0.0.24 → 0.0.26
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];
|
|
@@ -413,6 +589,16 @@ class BotiumConnectorVoip {
|
|
|
413
589
|
tts_body: this.caps[Capabilities.VOIP_TTS_BODY] || null
|
|
414
590
|
}
|
|
415
591
|
};
|
|
592
|
+
_info('initcall_sent', {
|
|
593
|
+
sipCallerAddress: request.SIP_CALLER_ADDRESS,
|
|
594
|
+
sipRegistrarUri: request.SIP_CALLER_REGISTRAR_URI,
|
|
595
|
+
sipCalleeUri: request.SIP_CALLEE_URI,
|
|
596
|
+
sipCallerUsername: request.SIP_CALLER_USERNAME,
|
|
597
|
+
sipProxy: request.SIP_PROXY || null,
|
|
598
|
+
iceEnable: request.ICE_ENABLE,
|
|
599
|
+
sttUrl: request.STT_CONFIG && request.STT_CONFIG.stt_url,
|
|
600
|
+
ttsUrl: request.TTS_CONFIG && request.TTS_CONFIG.tts_url
|
|
601
|
+
});
|
|
416
602
|
debug$3(JSON.stringify(request, null, 2));
|
|
417
603
|
this.ws.send(JSON.stringify(request));
|
|
418
604
|
this.ws.on('error', err => {
|
|
@@ -423,12 +609,45 @@ class BotiumConnectorVoip {
|
|
|
423
609
|
}
|
|
424
610
|
});
|
|
425
611
|
this.ws.on('message', async data => {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
612
|
+
// Drop non-JSON gateway frames (rare, but crash the process without this guard).
|
|
613
|
+
let parsedData;
|
|
614
|
+
try {
|
|
615
|
+
parsedData = JSON.parse(data);
|
|
616
|
+
// Earliest local observation of the frame. Consumed downstream
|
|
617
|
+
// as the "bot finished speaking" anchor for `recordingEpochMs`
|
|
618
|
+
// (propagates via `sourceData = parsedData` reference).
|
|
619
|
+
parsedData._receivedAtMs = Date.now();
|
|
620
|
+
} catch (parseErr) {
|
|
621
|
+
const rawString = (() => {
|
|
622
|
+
try {
|
|
623
|
+
if (Buffer.isBuffer(data)) return data.toString('utf8');
|
|
624
|
+
if (typeof data === 'string') return data;
|
|
625
|
+
if (data && typeof data.toString === 'function') return data.toString();
|
|
626
|
+
return '<non-stringable>';
|
|
627
|
+
} catch (_) {
|
|
628
|
+
return '<unreadable>';
|
|
629
|
+
}
|
|
630
|
+
})();
|
|
631
|
+
const preview = rawString.length > 200 ? `${rawString.substring(0, 200)}...` : rawString;
|
|
632
|
+
debug$3(`${this.sessionId} - Ignoring non-JSON WS frame from gateway: ${preview}`);
|
|
633
|
+
if (typeof _info === 'function') {
|
|
634
|
+
try {
|
|
635
|
+
_info('ws_non_json_frame', {
|
|
636
|
+
sessionId: this.sessionId,
|
|
637
|
+
preview,
|
|
638
|
+
byteLength: rawString.length,
|
|
639
|
+
parseError: parseErr && parseErr.message ? parseErr.message : String(parseErr)
|
|
640
|
+
});
|
|
641
|
+
} catch (_infoErr) {/* structured logger is best-effort */}
|
|
642
|
+
}
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
// After Stop(), still accept late-arriving recording frames
|
|
646
|
+
// (fullRecord*) and hard errors so `full_record.wav` is delivered
|
|
647
|
+
// on early-completion hangups. Post-Stop STT frames remain blocked.
|
|
429
648
|
if (this.stopCalled) {
|
|
430
|
-
const
|
|
431
|
-
if (!parsedData || !
|
|
649
|
+
const allowedPostStopTypes = ['fullRecord', 'fullRecordStart', 'fullRecordChunk', 'fullRecordEnd', 'error'];
|
|
650
|
+
if (!parsedData || !allowedPostStopTypes.includes(parsedData.type)) {
|
|
432
651
|
debug$3(`${this.sessionId} - Stop already called, ignoring incoming message`);
|
|
433
652
|
return;
|
|
434
653
|
}
|
|
@@ -520,9 +739,22 @@ class BotiumConnectorVoip {
|
|
|
520
739
|
return;
|
|
521
740
|
}
|
|
522
741
|
if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'unauthorized') {
|
|
742
|
+
_info('callinfo_sip_unauthorized', {
|
|
743
|
+
sessionId: this.sessionId,
|
|
744
|
+
event: parsedData.event || null,
|
|
745
|
+
sipCallerUsername: this.caps[Capabilities.VOIP_SIP_CALLER_USERNAME],
|
|
746
|
+
sipRegistrarUri: this.caps[Capabilities.VOIP_SIP_CALLER_REGISTRAR_URI]
|
|
747
|
+
});
|
|
523
748
|
reject(new Error('Error: Cannot open a call: SIP Authorization failed'));
|
|
524
749
|
}
|
|
525
750
|
if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'forbidden' && parsedData.event === 'onCallRegState') {
|
|
751
|
+
_info('callinfo_sip_reg_failed', {
|
|
752
|
+
sessionId: this.sessionId,
|
|
753
|
+
event: parsedData.event || null,
|
|
754
|
+
sipCallerAddress: this.caps[Capabilities.VOIP_SIP_CALLER_ADDRESS],
|
|
755
|
+
sipCallerUsername: this.caps[Capabilities.VOIP_SIP_CALLER_USERNAME],
|
|
756
|
+
sipRegistrarUri: this.caps[Capabilities.VOIP_SIP_CALLER_REGISTRAR_URI]
|
|
757
|
+
});
|
|
526
758
|
reject(new Error('Error: Sip Registration failed'));
|
|
527
759
|
}
|
|
528
760
|
if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'connected') {
|
|
@@ -537,6 +769,7 @@ class BotiumConnectorVoip {
|
|
|
537
769
|
connectDurationSec: parsedData.connectDuration || null,
|
|
538
770
|
sttPartialCount: this.sttPartialCount
|
|
539
771
|
});
|
|
772
|
+
flushPendingBotMsgs('callinfo_disconnected');
|
|
540
773
|
const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_STT_BODY));
|
|
541
774
|
if (parsedData.connectDuration && parsedData.connectDuration > 0) {
|
|
542
775
|
this.eventEmitter.emit('CONSUMPTION_METADATA', this, {
|
|
@@ -548,7 +781,20 @@ class BotiumConnectorVoip {
|
|
|
548
781
|
});
|
|
549
782
|
}
|
|
550
783
|
}
|
|
784
|
+
if (parsedData && parsedData.type === 'callinfo' && !['initialized', 'unauthorized', 'forbidden', 'connected', 'disconnected'].includes(parsedData.status)) {
|
|
785
|
+
_info('callinfo_unknown_status', {
|
|
786
|
+
sessionId: this.sessionId,
|
|
787
|
+
status: parsedData.status || null,
|
|
788
|
+
event: parsedData.event || null
|
|
789
|
+
});
|
|
790
|
+
}
|
|
551
791
|
if (parsedData && parsedData.type === 'error') {
|
|
792
|
+
flushPendingBotMsgs('error');
|
|
793
|
+
_info('ws_error_msg', {
|
|
794
|
+
sessionId: this.sessionId,
|
|
795
|
+
message: parsedData.message || null,
|
|
796
|
+
code: parsedData.code || null
|
|
797
|
+
});
|
|
552
798
|
this.end = true;
|
|
553
799
|
reject(new Error(`Error: ${parsedData.message}`));
|
|
554
800
|
sendBotMsg(new Error(`Error: ${parsedData.message}`));
|
|
@@ -573,11 +819,15 @@ class BotiumConnectorVoip {
|
|
|
573
819
|
source: 'fullRecordEnd',
|
|
574
820
|
base64Len
|
|
575
821
|
});
|
|
822
|
+
// Flush before `this.end = true` so the buffered final STT is not
|
|
823
|
+
// dropped when Stop() clears the PSST silence timer on teardown.
|
|
824
|
+
flushPendingBotMsgs('fullRecordEnd');
|
|
576
825
|
this.end = true;
|
|
577
826
|
}
|
|
578
827
|
if (parsedData && parsedData.type === 'silence') {
|
|
579
828
|
if (lodash__default["default"].isNil(this._getIgnoreSilenceDurationAsserterLogicHook(this.convoStep))) {
|
|
580
829
|
if (lodash__default["default"].isNil(this._getJoinLogicHook(this.convoStep)) && parsedData.data.silence.length > 0) {
|
|
830
|
+
flushPendingBotMsgs('silence_exceeded');
|
|
581
831
|
this.end = true;
|
|
582
832
|
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
833
|
}
|
|
@@ -592,12 +842,69 @@ class BotiumConnectorVoip {
|
|
|
592
842
|
source: 'fullRecord',
|
|
593
843
|
base64Len: (base64 || '').length
|
|
594
844
|
});
|
|
845
|
+
flushPendingBotMsgs('fullRecord');
|
|
595
846
|
this.end = true;
|
|
596
847
|
}
|
|
597
848
|
if (parsedData && parsedData.data && parsedData.data.final === false) {
|
|
598
849
|
this.sttPartialCount++;
|
|
850
|
+
const partialText = parsedData.data.message;
|
|
851
|
+
if (typeof partialText === 'string' && partialText.trim().length > 0) {
|
|
852
|
+
this.lastPartialBotMsg = {
|
|
853
|
+
messageText: partialText,
|
|
854
|
+
sourceData: Object.assign({}, parsedData, {
|
|
855
|
+
partialRecovery: true
|
|
856
|
+
})
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
const partialPreview = typeof partialText === 'string' ? partialText.trim().substring(0, 60) : '';
|
|
860
|
+
_info('stt_partial_received', {
|
|
861
|
+
sessionId: this.sessionId,
|
|
862
|
+
partialIndex: this.sttPartialCount,
|
|
863
|
+
preview: partialPreview,
|
|
864
|
+
bufferedChunks: this.botMsgs && this.botMsgs.length || 0,
|
|
865
|
+
timerActive: !!this.silenceTimeout
|
|
866
|
+
});
|
|
867
|
+
// Emit liveness keep-alive so waiters can distinguish "bot silent"
|
|
868
|
+
// from "bot streaming partials between finals".
|
|
869
|
+
if (this.caps[Capabilities.VOIP_EMIT_SPECULATIVE_TEXT] && this.eventEmitter && typeof partialText === 'string' && partialText.trim().length > 0) {
|
|
870
|
+
this.eventEmitter.emit('voip.botActivity', {
|
|
871
|
+
sessionId: this.sessionId,
|
|
872
|
+
kind: 'partial',
|
|
873
|
+
partialIndex: this.sttPartialCount,
|
|
874
|
+
bufferedChunks: this.botMsgs && this.botMsgs.length || 0
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
// PSST partial timer logic:
|
|
878
|
+
// Empty buffer → clear timer (final will arm fresh).
|
|
879
|
+
// Buffered finals + new-utterance partial → re-arm within cap.
|
|
880
|
+
// Tail/echo or cap-exceeded partials → ignore (legacy path).
|
|
599
881
|
if (this.silenceTimeout) {
|
|
600
|
-
|
|
882
|
+
if (!this.botMsgs || this.botMsgs.length === 0) {
|
|
883
|
+
clearTimeout(this.silenceTimeout);
|
|
884
|
+
this.silenceTimeout = null;
|
|
885
|
+
} else {
|
|
886
|
+
const sttHandling = this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING];
|
|
887
|
+
const isPsstMode = sttHandling === 'PSST';
|
|
888
|
+
if (isPsstMode && typeof partialText === 'string' && partialText.trim().length > 0) {
|
|
889
|
+
const MAX_EXTENSION_MS = 60000;
|
|
890
|
+
const now = Date.now();
|
|
891
|
+
const withinCap = this.psstFirstRearmAt == null || now - this.psstFirstRearmAt < MAX_EXTENSION_MS;
|
|
892
|
+
if (withinCap && partialLooksLikeNewUtterance(partialText, this.botMsgs)) {
|
|
893
|
+
if (this.psstFirstRearmAt == null) this.psstFirstRearmAt = now;
|
|
894
|
+
this.psstRearmCount++;
|
|
895
|
+
_info('psst_timer_extended', {
|
|
896
|
+
sessionId: this.sessionId,
|
|
897
|
+
rearmCount: this.psstRearmCount,
|
|
898
|
+
msSinceFirstRearm: now - this.psstFirstRearmAt,
|
|
899
|
+
bufferedChunks: this.botMsgs.length,
|
|
900
|
+
reason: 'new_utterance_partial',
|
|
901
|
+
partialPreview
|
|
902
|
+
});
|
|
903
|
+
// armJoinSilenceTimer() handles clearing the existing timeout.
|
|
904
|
+
armJoinSilenceTimer();
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
}
|
|
601
908
|
}
|
|
602
909
|
if (lodash__default["default"].isNil(this._getIgnoreSilenceDurationAsserterLogicHook(this.convoStep))) {
|
|
603
910
|
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 +932,7 @@ class BotiumConnectorVoip {
|
|
|
625
932
|
matched = true;
|
|
626
933
|
}
|
|
627
934
|
} else if (lodash__default["default"].isNil(joinLogicHook) && isJoinMethod) {
|
|
628
|
-
const joinTimeoutMs = toJoinTimeoutMs(this.
|
|
935
|
+
const joinTimeoutMs = toJoinTimeoutMs(this._getEffectiveMessageHandlingTimeout());
|
|
629
936
|
if (lodash__default["default"].isFinite(joinTimeoutMs) && joinTimeoutMs > 0 && silenceDuration > joinTimeoutMs / 1000) {
|
|
630
937
|
matched = true;
|
|
631
938
|
}
|
|
@@ -645,6 +952,8 @@ class BotiumConnectorVoip {
|
|
|
645
952
|
const msgText = parsedData.data.message || '';
|
|
646
953
|
const msgLen = typeof msgText === 'string' ? msgText.length : 0;
|
|
647
954
|
const msgPreview = typeof msgText === 'string' ? msgText.trim().substring(0, 80) : '';
|
|
955
|
+
// A final supersedes the cached interim; clear to avoid duplicate tail emission.
|
|
956
|
+
this.lastPartialBotMsg = null;
|
|
648
957
|
debug$3(`Message: ${parsedData.data.message} / Confidence Score: ${this._getConfidenceScore(parsedData)} (Threshold: ${confidenceThreshold})`);
|
|
649
958
|
_info('stt_final', {
|
|
650
959
|
sessionId: this.sessionId,
|
|
@@ -722,27 +1031,30 @@ class BotiumConnectorVoip {
|
|
|
722
1031
|
this.prevData = parsedData;
|
|
723
1032
|
if (successfulConfidenceScore) {
|
|
724
1033
|
this.botMsgs.push(botMsg);
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
1034
|
+
if (this.caps[Capabilities.VOIP_EMIT_SPECULATIVE_TEXT] && this.eventEmitter) {
|
|
1035
|
+
const tentative = joinBotMsg(this.botMsgs, this.joinLastPrevMsg);
|
|
1036
|
+
this._speculativeTurnToken++;
|
|
1037
|
+
// speech{Start,End}Sec are audio-clock offsets (seconds since
|
|
1038
|
+
// call connect) taken from the STT worker, so downstream
|
|
1039
|
+
// consumers can align with the recording playhead directly.
|
|
1040
|
+
const firstChunkStart = lodash__default["default"].get(this.botMsgs, '[0].sourceData.data.start', null);
|
|
1041
|
+
const thisFrameStart = lodash__default["default"].get(parsedData, 'data.start', null);
|
|
1042
|
+
const thisFrameEnd = lodash__default["default"].get(parsedData, 'data.end', null);
|
|
1043
|
+
const speechStartSec = lodash__default["default"].isFinite(firstChunkStart) ? firstChunkStart : lodash__default["default"].isFinite(thisFrameStart) ? thisFrameStart : null;
|
|
1044
|
+
const speechEndSec = lodash__default["default"].isFinite(thisFrameEnd) ? thisFrameEnd : null;
|
|
1045
|
+
this.eventEmitter.emit('voip.speculativeBotText', {
|
|
1046
|
+
sessionId: this.sessionId,
|
|
1047
|
+
messageText: tentative.messageText,
|
|
1048
|
+
turnToken: this._speculativeTurnToken,
|
|
1049
|
+
speechStartSec,
|
|
1050
|
+
speechEndSec
|
|
1051
|
+
});
|
|
736
1052
|
}
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
this.joinLastPrevMsg = this.botMsgs[this.botMsgs.length - 1];
|
|
743
|
-
this.botMsgs = [];
|
|
744
|
-
}
|
|
745
|
-
}, joinTimeoutMs || 0);
|
|
1053
|
+
|
|
1054
|
+
// New final arrived — reset extension budget for next pause.
|
|
1055
|
+
this.psstRearmCount = 0;
|
|
1056
|
+
this.psstFirstRearmAt = null;
|
|
1057
|
+
armJoinSilenceTimer();
|
|
746
1058
|
}
|
|
747
1059
|
}
|
|
748
1060
|
}
|
|
@@ -795,6 +1107,22 @@ class BotiumConnectorVoip {
|
|
|
795
1107
|
const preferVoice = !!preferVoiceCapRaw;
|
|
796
1108
|
const skipTtsForMixedInput = preferVoice && hasText && hasVoiceMedia;
|
|
797
1109
|
debug$3(`UserSays routing: hasText=${hasText} hasVoiceMedia=${hasVoiceMedia} preferVoice=${preferVoice} preferVoiceRaw=${JSON.stringify(preferVoiceCapRaw)} skipTtsForMixedInput=${skipTtsForMixedInput}`);
|
|
1110
|
+
|
|
1111
|
+
// Stamp `msg.voipAgent` at the moment bytes leave the WebSocket so
|
|
1112
|
+
// the coach can place the agent turn on the recording timeline.
|
|
1113
|
+
// `requestedDurationMs` is the best estimate of on-wire playback
|
|
1114
|
+
// length (DTMF tones × digits, TTS synth output, parsed media duration).
|
|
1115
|
+
const stampAgentWire = (wireKind, requestedDurationMs, extras = {}) => {
|
|
1116
|
+
msg.voipAgent = {
|
|
1117
|
+
wireSentAtMs: Date.now(),
|
|
1118
|
+
inputType,
|
|
1119
|
+
wireKind,
|
|
1120
|
+
requestedDurationMs: Math.max(0, Math.round(requestedDurationMs || 0)),
|
|
1121
|
+
...extras
|
|
1122
|
+
};
|
|
1123
|
+
};
|
|
1124
|
+
// Twilio default: 100 ms tone + 100 ms gap per digit. Drives agent-bar width only.
|
|
1125
|
+
const DTMF_MS_PER_DIGIT = 200;
|
|
798
1126
|
if (msg && msg.buttons && msg.buttons.length > 0) {
|
|
799
1127
|
const digits = sanitizeDtmfDigits(msg.buttons[0].payload);
|
|
800
1128
|
if (!digits) {
|
|
@@ -807,6 +1135,9 @@ class BotiumConnectorVoip {
|
|
|
807
1135
|
digits,
|
|
808
1136
|
sessionId: this.sessionId
|
|
809
1137
|
});
|
|
1138
|
+
stampAgentWire('dtmf', digits.length * DTMF_MS_PER_DIGIT, {
|
|
1139
|
+
digitCount: digits.length
|
|
1140
|
+
});
|
|
810
1141
|
this.ws.send(request);
|
|
811
1142
|
} else if (msg && msg.messageText) {
|
|
812
1143
|
// Check for DTMF tag in messageText: <DTMF>1234</DTMF>
|
|
@@ -828,6 +1159,9 @@ class BotiumConnectorVoip {
|
|
|
828
1159
|
digits,
|
|
829
1160
|
sessionId: this.sessionId
|
|
830
1161
|
});
|
|
1162
|
+
stampAgentWire('dtmf', digits.length * DTMF_MS_PER_DIGIT, {
|
|
1163
|
+
digitCount: digits.length
|
|
1164
|
+
});
|
|
831
1165
|
this.ws.send(request);
|
|
832
1166
|
return resolve();
|
|
833
1167
|
}
|
|
@@ -842,8 +1176,11 @@ class BotiumConnectorVoip {
|
|
|
842
1176
|
if (!ttsRequest) return reject(new Error('TTS not configured, only audio input supported'));
|
|
843
1177
|
msg.sourceData = ttsRequest;
|
|
844
1178
|
let ttsResult = null;
|
|
1179
|
+
const ttsStartedAt = Date.now();
|
|
1180
|
+
let ttsSynthMs = 0;
|
|
845
1181
|
try {
|
|
846
1182
|
ttsResult = await this._getTtsAudio(ttsRequest, msg.messageText);
|
|
1183
|
+
ttsSynthMs = Date.now() - ttsStartedAt;
|
|
847
1184
|
} catch (err) {
|
|
848
1185
|
return reject(new Error(`TTS "${msg.messageText}" failed - ${this._getAxiosErrOutput(err)}`));
|
|
849
1186
|
}
|
|
@@ -875,6 +1212,10 @@ class BotiumConnectorVoip {
|
|
|
875
1212
|
mimeType: 'audio/wav',
|
|
876
1213
|
base64: b64Buffer
|
|
877
1214
|
});
|
|
1215
|
+
stampAgentWire('tts', (duration || 0) * 1000, {
|
|
1216
|
+
ttsSynthMs,
|
|
1217
|
+
textLength: msg.messageText ? msg.messageText.length : 0
|
|
1218
|
+
});
|
|
878
1219
|
this.ws.send(request);
|
|
879
1220
|
} else {
|
|
880
1221
|
return reject(new Error('TTS failed, response is empty'));
|
|
@@ -896,6 +1237,10 @@ class BotiumConnectorVoip {
|
|
|
896
1237
|
const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>';
|
|
897
1238
|
debug$3(`Latency (bot says -> sendAudio/MEDIA): ${latencyMs} ms (last bot: ${botText})`);
|
|
898
1239
|
}
|
|
1240
|
+
// Stamp now; `requestedDurationMs` is backfilled once media metadata is parsed.
|
|
1241
|
+
stampAgentWire('media', 0, {
|
|
1242
|
+
mediaUri: msg.media[0].mediaUri || null
|
|
1243
|
+
});
|
|
899
1244
|
this.ws.send(request);
|
|
900
1245
|
msg.attachments.push({
|
|
901
1246
|
name: msg.media[0].mediaUri,
|
|
@@ -907,6 +1252,9 @@ class BotiumConnectorVoip {
|
|
|
907
1252
|
if (metadata && metadata.format && metadata.format.duration) {
|
|
908
1253
|
debug$3('Audio duration of user audio:', metadata.format.duration, 'seconds');
|
|
909
1254
|
duration = Math.round(metadata.format.duration);
|
|
1255
|
+
if (msg.voipAgent) {
|
|
1256
|
+
msg.voipAgent.requestedDurationMs = Math.max(0, Math.round((duration || 0) * 1000));
|
|
1257
|
+
}
|
|
910
1258
|
} else {
|
|
911
1259
|
reject(new Error('Could not determine audio duration from metadata'));
|
|
912
1260
|
}
|
|
@@ -914,7 +1262,11 @@ class BotiumConnectorVoip {
|
|
|
914
1262
|
reject(new Error(`Getting audio duration failed: ${err.message}`));
|
|
915
1263
|
}
|
|
916
1264
|
}
|
|
917
|
-
|
|
1265
|
+
const requestedDurationMs = Math.max(0, Math.round((duration || 0) * 1000));
|
|
1266
|
+
if (requestedDurationMs <= 0) {
|
|
1267
|
+
return resolve();
|
|
1268
|
+
}
|
|
1269
|
+
setTimeout(resolve, requestedDurationMs);
|
|
918
1270
|
}, 0);
|
|
919
1271
|
});
|
|
920
1272
|
}
|
|
@@ -1120,6 +1472,16 @@ class BotiumConnectorVoip {
|
|
|
1120
1472
|
_extractApiKey(body) {
|
|
1121
1473
|
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
1474
|
}
|
|
1475
|
+
|
|
1476
|
+
// After the first flush, use the shorter SUBSEQUENT timeout if configured.
|
|
1477
|
+
// Falls back to the global timeout — fully backward-compatible.
|
|
1478
|
+
_getEffectiveMessageHandlingTimeout() {
|
|
1479
|
+
if (!this.firstMsg) {
|
|
1480
|
+
const sub = parseInt(this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_TIMEOUT_SUBSEQUENT], 10);
|
|
1481
|
+
if (lodash__default["default"].isFinite(sub) && sub > 0) return sub;
|
|
1482
|
+
}
|
|
1483
|
+
return this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_TIMEOUT];
|
|
1484
|
+
}
|
|
1123
1485
|
_getJoinLogicHook(convoStep) {
|
|
1124
1486
|
if (lodash__default["default"].isNil(convoStep)) return null;
|
|
1125
1487
|
if (lodash__default["default"].isNil(convoStep.logicHooks)) return null;
|