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
- 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];
@@ -423,12 +599,45 @@ class BotiumConnectorVoip {
423
599
  }
424
600
  });
425
601
  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).
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 allowedTypes = ['fullRecord', 'fullRecordStart', 'fullRecordChunk', 'fullRecordEnd', 'error'];
431
- if (!parsedData || !parsedData.type || !allowedTypes.includes(parsedData.type)) {
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
- clearTimeout(this.silenceTimeout);
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.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_TIMEOUT]);
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
- 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]);
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
- 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);
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
- setTimeout(resolve, duration * 1000);
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;