@eshal-bot/chat-widget 0.1.43 → 0.1.45

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.
@@ -8537,6 +8537,151 @@
8537
8537
  return pcm16Array.buffer;
8538
8538
  }
8539
8539
 
8540
+ /**
8541
+ * Client-side Voice-Activity gate for the widget's voice (Bidi) session.
8542
+ *
8543
+ * Ported from packages/agent-sdk/src/voice/audio/vad.ts (`createSpeechGate`),
8544
+ * which was itself originally derived from this widget. The gate sits between
8545
+ * the mic worklet and the WebSocket: it forwards PCM ONLY inside a confirmed
8546
+ * speech turn and fires `onSpeechEnd` when the turn closes, so the caller can
8547
+ * tell the backend the utterance is done (an `audio_end` signal).
8548
+ *
8549
+ * Why the widget needs this: knowledge-api's non-streaming voice path only
8550
+ * transcribes a turn when it receives `audio_end` (or its flaky energy-VAD
8551
+ * backstop trips). Without a client gate the widget streamed mic audio forever
8552
+ * and the backend just buffered it ("Collecting Audio…") without ever
8553
+ * transcribing — so voice connected but never replied. The agent-sdk already
8554
+ * gates this way; this brings the widget to parity.
8555
+ *
8556
+ * Framework-free and side-effect-free (only calls the two callbacks) so it can
8557
+ * be reasoned about / tested in isolation.
8558
+ */
8559
+
8560
+ const DEFAULT_SPEECH_GATE_CONFIG = {
8561
+ // RMS energy (0..1) at/above which a frame counts as voiced. Speech is
8562
+ // typically 0.05–0.3; quiet room tone sits well under 0.02.
8563
+ energyThreshold: 0.025,
8564
+ // Continuous voiced audio (ms) required to OPEN a turn — rejects coughs/clicks.
8565
+ speechStartMs: 200,
8566
+ // Continuous silence (ms) after speech that CLOSES the turn. Long enough to
8567
+ // ride over natural mid-sentence pauses, short enough to feel responsive.
8568
+ hangoverMs: 1000,
8569
+ // How much audio (ms) preceding turn-open to retain + flush so the leading
8570
+ // phoneme of the first word survives the start-detection delay.
8571
+ preRollMs: 300
8572
+ };
8573
+
8574
+ /** RMS amplitude of an Int16 PCM frame (ArrayBuffer), normalised to 0..1. */
8575
+ function computeRms(pcm) {
8576
+ const samples = new Int16Array(pcm);
8577
+ if (samples.length === 0) return 0;
8578
+ let sumSquares = 0;
8579
+ for (let i = 0; i < samples.length; i += 1) {
8580
+ const v = samples[i] / 0x8000; // normalise Int16 → -1..1
8581
+ sumSquares += v * v;
8582
+ }
8583
+ return Math.sqrt(sumSquares / samples.length);
8584
+ }
8585
+
8586
+ /**
8587
+ * Create a stateful speech gate. Returns `{ process, reset, isSpeaking }`.
8588
+ * `config.sampleRate` is required; everything else falls back to
8589
+ * DEFAULT_SPEECH_GATE_CONFIG.
8590
+ *
8591
+ * @param {{ sampleRate: number, energyThreshold?: number, speechStartMs?: number, hangoverMs?: number, preRollMs?: number }} config
8592
+ * @param {{ onSpeechStart?: () => void, onVoiceFrame: (pcm: ArrayBuffer) => void, onSpeechEnd: () => void }} callbacks
8593
+ * onSpeechStart fires the instant a turn opens — use it for barge-in
8594
+ * (stop any assistant TTS still playing). onVoiceFrame emits each voiced
8595
+ * PCM frame; onSpeechEnd fires when the turn closes after the hangover.
8596
+ */
8597
+ function createSpeechGate(config, callbacks) {
8598
+ var _config$energyThresho, _config$speechStartMs, _config$hangoverMs, _config$preRollMs;
8599
+ const cfg = {
8600
+ sampleRate: config.sampleRate,
8601
+ energyThreshold: (_config$energyThresho = config.energyThreshold) !== null && _config$energyThresho !== void 0 ? _config$energyThresho : DEFAULT_SPEECH_GATE_CONFIG.energyThreshold,
8602
+ speechStartMs: (_config$speechStartMs = config.speechStartMs) !== null && _config$speechStartMs !== void 0 ? _config$speechStartMs : DEFAULT_SPEECH_GATE_CONFIG.speechStartMs,
8603
+ hangoverMs: (_config$hangoverMs = config.hangoverMs) !== null && _config$hangoverMs !== void 0 ? _config$hangoverMs : DEFAULT_SPEECH_GATE_CONFIG.hangoverMs,
8604
+ preRollMs: (_config$preRollMs = config.preRollMs) !== null && _config$preRollMs !== void 0 ? _config$preRollMs : DEFAULT_SPEECH_GATE_CONFIG.preRollMs
8605
+ };
8606
+ let speaking = false;
8607
+ // While idle: how much consecutive voiced audio we've seen (to open).
8608
+ let voicedRunMs = 0;
8609
+ // While speaking: how much consecutive silence we've seen (to close).
8610
+ let silenceRunMs = 0;
8611
+ // Ring of recent frames kept only while idle, for pre-roll on open.
8612
+ let preRoll = [];
8613
+ let preRollMsBuffered = 0;
8614
+
8615
+ // Int16 → 2 bytes/sample; ms = samples / sampleRate * 1000.
8616
+ const frameDurationMs = pcm => pcm.byteLength / 2 / cfg.sampleRate * 1000;
8617
+ const reset = () => {
8618
+ speaking = false;
8619
+ voicedRunMs = 0;
8620
+ silenceRunMs = 0;
8621
+ preRoll = [];
8622
+ preRollMsBuffered = 0;
8623
+ };
8624
+ const process = pcm => {
8625
+ const durationMs = frameDurationMs(pcm);
8626
+ const voiced = computeRms(pcm) >= cfg.energyThreshold;
8627
+ if (!speaking) {
8628
+ // Keep a bounded pre-roll of the most recent audio so we don't clip the
8629
+ // onset once we decide this is speech.
8630
+ preRoll.push({
8631
+ pcm,
8632
+ durationMs
8633
+ });
8634
+ preRollMsBuffered += durationMs;
8635
+ while (preRollMsBuffered > cfg.preRollMs && preRoll.length > 1) {
8636
+ var _dropped$durationMs;
8637
+ const dropped = preRoll.shift();
8638
+ preRollMsBuffered -= (_dropped$durationMs = dropped === null || dropped === void 0 ? void 0 : dropped.durationMs) !== null && _dropped$durationMs !== void 0 ? _dropped$durationMs : 0;
8639
+ }
8640
+ if (voiced) {
8641
+ voicedRunMs += durationMs;
8642
+ if (voicedRunMs >= cfg.speechStartMs) {
8643
+ var _callbacks$onSpeechSt;
8644
+ // Confirmed speech — open the turn. Fire onSpeechStart FIRST so the
8645
+ // caller can barge in (stop assistant TTS still playing) before we
8646
+ // start streaming this utterance, then flush the pre-roll (which
8647
+ // contains the voiced onset) and go live.
8648
+ speaking = true;
8649
+ silenceRunMs = 0;
8650
+ (_callbacks$onSpeechSt = callbacks.onSpeechStart) === null || _callbacks$onSpeechSt === void 0 || _callbacks$onSpeechSt.call(callbacks);
8651
+ for (const frame of preRoll) callbacks.onVoiceFrame(frame.pcm);
8652
+ preRoll = [];
8653
+ preRollMsBuffered = 0;
8654
+ }
8655
+ } else {
8656
+ // Need *sustained* energy — reset on any silent frame so a brief
8657
+ // transient can't accumulate its way over the threshold.
8658
+ voicedRunMs = 0;
8659
+ }
8660
+ return;
8661
+ }
8662
+
8663
+ // Inside a confirmed turn: forward everything, including short internal
8664
+ // pauses, until the hangover elapses.
8665
+ callbacks.onVoiceFrame(pcm);
8666
+ if (voiced) {
8667
+ silenceRunMs = 0;
8668
+ } else {
8669
+ silenceRunMs += durationMs;
8670
+ if (silenceRunMs >= cfg.hangoverMs) {
8671
+ speaking = false;
8672
+ voicedRunMs = 0;
8673
+ silenceRunMs = 0;
8674
+ callbacks.onSpeechEnd();
8675
+ }
8676
+ }
8677
+ };
8678
+ return {
8679
+ process,
8680
+ reset,
8681
+ isSpeaking: () => speaking
8682
+ };
8683
+ }
8684
+
8540
8685
  /**
8541
8686
  * Clean CJK (Chinese, Japanese, Korean) spaces
8542
8687
  */
@@ -9059,6 +9204,11 @@
9059
9204
  const audioRecorderNodeRef = reactExports.useRef(null);
9060
9205
  const audioRecorderContextRef = reactExports.useRef(null);
9061
9206
  const micStreamRef = reactExports.useRef(null);
9207
+ // Client-side VAD gate. Forwards only voiced PCM upstream and fires
9208
+ // `audio_end` on end-of-speech so the backend's non-streaming voice path
9209
+ // transcribes the turn (mirrors the agent-sdk). Without it the backend
9210
+ // buffers audio forever and never responds.
9211
+ const speechGateRef = reactExports.useRef(null);
9062
9212
  const currentInputMessageIdRef = reactExports.useRef(null);
9063
9213
  const currentAssistantMessageIdRef = reactExports.useRef(null);
9064
9214
  const isAudioReadyRef = reactExports.useRef(false);
@@ -10193,7 +10343,7 @@
10193
10343
  }));
10194
10344
  }, []);
10195
10345
  const stopVoiceSession = reactExports.useCallback(() => {
10196
- var _audioPlayerNodeRef$c, _audioPlayerContextRe, _audioRecorderNodeRef, _audioRecorderContext;
10346
+ var _audioPlayerNodeRef$c, _audioPlayerContextRe, _audioRecorderNodeRef, _audioRecorderContext, _speechGateRef$curren;
10197
10347
  setIsVoiceSessionActive(false);
10198
10348
  setIsLoading(false);
10199
10349
  isVoiceSessionActiveRef.current = false;
@@ -10217,6 +10367,8 @@
10217
10367
  audioRecorderContextRef.current = null;
10218
10368
  stopMicrophone(micStreamRef.current);
10219
10369
  micStreamRef.current = null;
10370
+ (_speechGateRef$curren = speechGateRef.current) === null || _speechGateRef$curren === void 0 || _speechGateRef$curren.reset();
10371
+ speechGateRef.current = null;
10220
10372
  isAudioReadyRef.current = false;
10221
10373
  }, []);
10222
10374
  const handleBidiEvent = reactExports.useCallback(event => {
@@ -10396,11 +10548,22 @@
10396
10548
  }
10397
10549
  }, [appendAssistantContent, appendUserTranscription, handleContentParts, handleInterrupted, handleTurnComplete, stopVoiceSession]);
10398
10550
  const handleRecorderAudio = reactExports.useCallback(pcmData => {
10399
- const ws = websocketRef.current;
10400
- if (!isVoiceSessionActiveRef.current || !ws || ws.readyState !== WebSocket.OPEN) {
10551
+ if (!isVoiceSessionActiveRef.current) {
10401
10552
  return;
10402
10553
  }
10403
- ws.send(pcmData);
10554
+ // Route mic frames through the VAD gate: it forwards only voiced frames
10555
+ // (onVoiceFrame → ws.send) and fires onSpeechEnd → audio_end so the
10556
+ // backend finalizes the turn. Fall back to raw streaming only if the
10557
+ // gate somehow wasn't built.
10558
+ const gate = speechGateRef.current;
10559
+ if (gate) {
10560
+ gate.process(pcmData);
10561
+ return;
10562
+ }
10563
+ const ws = websocketRef.current;
10564
+ if (ws && ws.readyState === WebSocket.OPEN) {
10565
+ ws.send(pcmData);
10566
+ }
10404
10567
  }, []);
10405
10568
  const initializeAudioPipeline = reactExports.useCallback(async () => {
10406
10569
  if (isAudioReadyRef.current || typeof window === "undefined") {
@@ -10411,6 +10574,49 @@
10411
10574
  audioPlayerNodeRef.current = playerNode;
10412
10575
  audioPlayerContextRef.current = playerContext;
10413
10576
  await playerContext.resume();
10577
+
10578
+ // Build the VAD gate before the recorder starts producing frames.
10579
+ // onVoiceFrame streams voiced PCM upstream; onSpeechEnd tells the backend
10580
+ // the utterance is complete so it transcribes + responds. The recorder
10581
+ // runs at 16 kHz (see audio-recorder.js).
10582
+ const sendVoicePcm = pcm => {
10583
+ const ws = websocketRef.current;
10584
+ if (ws && ws.readyState === WebSocket.OPEN) {
10585
+ ws.send(pcm);
10586
+ }
10587
+ };
10588
+ speechGateRef.current = createSpeechGate({
10589
+ sampleRate: 16000
10590
+ }, {
10591
+ // Barge-in: the instant the user starts talking, stop any assistant
10592
+ // TTS still playing so we don't talk over them. endOfAudio drops the
10593
+ // player's buffered audio immediately (readIndex → writeIndex). No-op
10594
+ // when nothing is playing (e.g. the user's first utterance).
10595
+ onSpeechStart: () => {
10596
+ var _audioPlayerNodeRef$c2;
10597
+ // Barge-in: user started talking → (1) stop assistant TTS already
10598
+ // buffered locally, and (2) tell the backend to cancel the in-flight
10599
+ // reply so its remaining TTS doesn't clobber the next turn's audio.
10600
+ (_audioPlayerNodeRef$c2 = audioPlayerNodeRef.current) === null || _audioPlayerNodeRef$c2 === void 0 || _audioPlayerNodeRef$c2.port.postMessage({
10601
+ command: "endOfAudio"
10602
+ });
10603
+ const ws = websocketRef.current;
10604
+ if (ws && ws.readyState === WebSocket.OPEN) {
10605
+ ws.send(JSON.stringify({
10606
+ type: "interrupt"
10607
+ }));
10608
+ }
10609
+ },
10610
+ onVoiceFrame: sendVoicePcm,
10611
+ onSpeechEnd: () => {
10612
+ const ws = websocketRef.current;
10613
+ if (ws && ws.readyState === WebSocket.OPEN) {
10614
+ ws.send(JSON.stringify({
10615
+ type: "audio_end"
10616
+ }));
10617
+ }
10618
+ }
10619
+ });
10414
10620
  const [recorderNode, recorderContext, micStream] = await startAudioRecorderWorklet(handleRecorderAudio);
10415
10621
  audioRecorderNodeRef.current = recorderNode;
10416
10622
  audioRecorderContextRef.current = recorderContext;
@@ -10573,11 +10779,11 @@
10573
10779
  }, [isVoiceSessionActive]);
10574
10780
  reactExports.useEffect(() => {
10575
10781
  return () => {
10576
- var _audioPlayerNodeRef$c2, _audioPlayerContextRe2, _audioRecorderNodeRef2, _audioRecorderContext2;
10782
+ var _audioPlayerNodeRef$c3, _audioPlayerContextRe2, _audioRecorderNodeRef2, _audioRecorderContext2;
10577
10783
  if (websocketRef.current && websocketRef.current.readyState === WebSocket.OPEN) {
10578
10784
  websocketRef.current.close();
10579
10785
  }
10580
- (_audioPlayerNodeRef$c2 = audioPlayerNodeRef.current) === null || _audioPlayerNodeRef$c2 === void 0 || _audioPlayerNodeRef$c2.disconnect();
10786
+ (_audioPlayerNodeRef$c3 = audioPlayerNodeRef.current) === null || _audioPlayerNodeRef$c3 === void 0 || _audioPlayerNodeRef$c3.disconnect();
10581
10787
  (_audioPlayerContextRe2 = audioPlayerContextRef.current) === null || _audioPlayerContextRe2 === void 0 || _audioPlayerContextRe2.close();
10582
10788
  (_audioRecorderNodeRef2 = audioRecorderNodeRef.current) === null || _audioRecorderNodeRef2 === void 0 || _audioRecorderNodeRef2.disconnect();
10583
10789
  (_audioRecorderContext2 = audioRecorderContextRef.current) === null || _audioRecorderContext2 === void 0 || _audioRecorderContext2.close();