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
- this._lastBotSaysQueuedAt = Date.now();
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 global timeout if no per-step hook is set
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.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_TIMEOUT], 10);
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
- const parsedData = JSON.parse(data);
427
- // Allow fullRecord delivery even if Stop() was already called.
428
- // Otherwise the recording can be dropped if the worker streams it late (common on hangup).
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 allowedTypes = ['fullRecord', 'fullRecordStart', 'fullRecordChunk', 'fullRecordEnd', 'error'];
431
- if (!parsedData || !parsedData.type || !allowedTypes.includes(parsedData.type)) {
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
- clearTimeout(this.silenceTimeout);
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.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_TIMEOUT]);
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
- const sttHandling = this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING];
726
- const isPsst = sttHandling === 'PSST';
727
- const toJoinTimeoutMs = ms => {
728
- const parsed = parseInt(ms, 10);
729
- if (!lodash__default["default"].isFinite(parsed) || parsed <= 0) return null;
730
- return isPsst ? Math.max(0, parsed - 500) : parsed;
731
- };
732
- const joinLogicHook = this._getJoinLogicHook(this.convoStep);
733
- let joinTimeoutMs = toJoinTimeoutMs(lodash__default["default"].get(joinLogicHook, 'args[0]'));
734
- if (!lodash__default["default"].isFinite(joinTimeoutMs) || joinTimeoutMs <= 0) {
735
- joinTimeoutMs = toJoinTimeoutMs(this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_TIMEOUT]);
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
- this.silenceTimeout = setTimeout(() => {
738
- if (this.botMsgs.length > 0) {
739
- debug$3('Silence Duration Timeout (JOIN/PSST):', joinTimeoutMs, 'ms');
740
- sendBotMsg(joinBotMsg(this.botMsgs, this.joinLastPrevMsg));
741
- this.firstMsg = false;
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
- setTimeout(resolve, duration * 1000);
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;