@eshal-bot/chat-widget 0.1.43 → 0.1.44

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,144 @@
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 {{ onVoiceFrame: (pcm: ArrayBuffer) => void, onSpeechEnd: () => void }} callbacks
8593
+ */
8594
+ function createSpeechGate(config, callbacks) {
8595
+ var _config$energyThresho, _config$speechStartMs, _config$hangoverMs, _config$preRollMs;
8596
+ const cfg = {
8597
+ sampleRate: config.sampleRate,
8598
+ energyThreshold: (_config$energyThresho = config.energyThreshold) !== null && _config$energyThresho !== void 0 ? _config$energyThresho : DEFAULT_SPEECH_GATE_CONFIG.energyThreshold,
8599
+ speechStartMs: (_config$speechStartMs = config.speechStartMs) !== null && _config$speechStartMs !== void 0 ? _config$speechStartMs : DEFAULT_SPEECH_GATE_CONFIG.speechStartMs,
8600
+ hangoverMs: (_config$hangoverMs = config.hangoverMs) !== null && _config$hangoverMs !== void 0 ? _config$hangoverMs : DEFAULT_SPEECH_GATE_CONFIG.hangoverMs,
8601
+ preRollMs: (_config$preRollMs = config.preRollMs) !== null && _config$preRollMs !== void 0 ? _config$preRollMs : DEFAULT_SPEECH_GATE_CONFIG.preRollMs
8602
+ };
8603
+ let speaking = false;
8604
+ // While idle: how much consecutive voiced audio we've seen (to open).
8605
+ let voicedRunMs = 0;
8606
+ // While speaking: how much consecutive silence we've seen (to close).
8607
+ let silenceRunMs = 0;
8608
+ // Ring of recent frames kept only while idle, for pre-roll on open.
8609
+ let preRoll = [];
8610
+ let preRollMsBuffered = 0;
8611
+
8612
+ // Int16 → 2 bytes/sample; ms = samples / sampleRate * 1000.
8613
+ const frameDurationMs = pcm => pcm.byteLength / 2 / cfg.sampleRate * 1000;
8614
+ const reset = () => {
8615
+ speaking = false;
8616
+ voicedRunMs = 0;
8617
+ silenceRunMs = 0;
8618
+ preRoll = [];
8619
+ preRollMsBuffered = 0;
8620
+ };
8621
+ const process = pcm => {
8622
+ const durationMs = frameDurationMs(pcm);
8623
+ const voiced = computeRms(pcm) >= cfg.energyThreshold;
8624
+ if (!speaking) {
8625
+ // Keep a bounded pre-roll of the most recent audio so we don't clip the
8626
+ // onset once we decide this is speech.
8627
+ preRoll.push({
8628
+ pcm,
8629
+ durationMs
8630
+ });
8631
+ preRollMsBuffered += durationMs;
8632
+ while (preRollMsBuffered > cfg.preRollMs && preRoll.length > 1) {
8633
+ var _dropped$durationMs;
8634
+ const dropped = preRoll.shift();
8635
+ preRollMsBuffered -= (_dropped$durationMs = dropped === null || dropped === void 0 ? void 0 : dropped.durationMs) !== null && _dropped$durationMs !== void 0 ? _dropped$durationMs : 0;
8636
+ }
8637
+ if (voiced) {
8638
+ voicedRunMs += durationMs;
8639
+ if (voicedRunMs >= cfg.speechStartMs) {
8640
+ // Confirmed speech — open the turn and flush the pre-roll (which
8641
+ // contains the voiced onset) before going live.
8642
+ speaking = true;
8643
+ silenceRunMs = 0;
8644
+ for (const frame of preRoll) callbacks.onVoiceFrame(frame.pcm);
8645
+ preRoll = [];
8646
+ preRollMsBuffered = 0;
8647
+ }
8648
+ } else {
8649
+ // Need *sustained* energy — reset on any silent frame so a brief
8650
+ // transient can't accumulate its way over the threshold.
8651
+ voicedRunMs = 0;
8652
+ }
8653
+ return;
8654
+ }
8655
+
8656
+ // Inside a confirmed turn: forward everything, including short internal
8657
+ // pauses, until the hangover elapses.
8658
+ callbacks.onVoiceFrame(pcm);
8659
+ if (voiced) {
8660
+ silenceRunMs = 0;
8661
+ } else {
8662
+ silenceRunMs += durationMs;
8663
+ if (silenceRunMs >= cfg.hangoverMs) {
8664
+ speaking = false;
8665
+ voicedRunMs = 0;
8666
+ silenceRunMs = 0;
8667
+ callbacks.onSpeechEnd();
8668
+ }
8669
+ }
8670
+ };
8671
+ return {
8672
+ process,
8673
+ reset,
8674
+ isSpeaking: () => speaking
8675
+ };
8676
+ }
8677
+
8540
8678
  /**
8541
8679
  * Clean CJK (Chinese, Japanese, Korean) spaces
8542
8680
  */
@@ -9059,6 +9197,11 @@
9059
9197
  const audioRecorderNodeRef = reactExports.useRef(null);
9060
9198
  const audioRecorderContextRef = reactExports.useRef(null);
9061
9199
  const micStreamRef = reactExports.useRef(null);
9200
+ // Client-side VAD gate. Forwards only voiced PCM upstream and fires
9201
+ // `audio_end` on end-of-speech so the backend's non-streaming voice path
9202
+ // transcribes the turn (mirrors the agent-sdk). Without it the backend
9203
+ // buffers audio forever and never responds.
9204
+ const speechGateRef = reactExports.useRef(null);
9062
9205
  const currentInputMessageIdRef = reactExports.useRef(null);
9063
9206
  const currentAssistantMessageIdRef = reactExports.useRef(null);
9064
9207
  const isAudioReadyRef = reactExports.useRef(false);
@@ -10193,7 +10336,7 @@
10193
10336
  }));
10194
10337
  }, []);
10195
10338
  const stopVoiceSession = reactExports.useCallback(() => {
10196
- var _audioPlayerNodeRef$c, _audioPlayerContextRe, _audioRecorderNodeRef, _audioRecorderContext;
10339
+ var _audioPlayerNodeRef$c, _audioPlayerContextRe, _audioRecorderNodeRef, _audioRecorderContext, _speechGateRef$curren;
10197
10340
  setIsVoiceSessionActive(false);
10198
10341
  setIsLoading(false);
10199
10342
  isVoiceSessionActiveRef.current = false;
@@ -10217,6 +10360,8 @@
10217
10360
  audioRecorderContextRef.current = null;
10218
10361
  stopMicrophone(micStreamRef.current);
10219
10362
  micStreamRef.current = null;
10363
+ (_speechGateRef$curren = speechGateRef.current) === null || _speechGateRef$curren === void 0 || _speechGateRef$curren.reset();
10364
+ speechGateRef.current = null;
10220
10365
  isAudioReadyRef.current = false;
10221
10366
  }, []);
10222
10367
  const handleBidiEvent = reactExports.useCallback(event => {
@@ -10396,11 +10541,22 @@
10396
10541
  }
10397
10542
  }, [appendAssistantContent, appendUserTranscription, handleContentParts, handleInterrupted, handleTurnComplete, stopVoiceSession]);
10398
10543
  const handleRecorderAudio = reactExports.useCallback(pcmData => {
10399
- const ws = websocketRef.current;
10400
- if (!isVoiceSessionActiveRef.current || !ws || ws.readyState !== WebSocket.OPEN) {
10544
+ if (!isVoiceSessionActiveRef.current) {
10545
+ return;
10546
+ }
10547
+ // Route mic frames through the VAD gate: it forwards only voiced frames
10548
+ // (onVoiceFrame → ws.send) and fires onSpeechEnd → audio_end so the
10549
+ // backend finalizes the turn. Fall back to raw streaming only if the
10550
+ // gate somehow wasn't built.
10551
+ const gate = speechGateRef.current;
10552
+ if (gate) {
10553
+ gate.process(pcmData);
10401
10554
  return;
10402
10555
  }
10403
- ws.send(pcmData);
10556
+ const ws = websocketRef.current;
10557
+ if (ws && ws.readyState === WebSocket.OPEN) {
10558
+ ws.send(pcmData);
10559
+ }
10404
10560
  }, []);
10405
10561
  const initializeAudioPipeline = reactExports.useCallback(async () => {
10406
10562
  if (isAudioReadyRef.current || typeof window === "undefined") {
@@ -10411,6 +10567,30 @@
10411
10567
  audioPlayerNodeRef.current = playerNode;
10412
10568
  audioPlayerContextRef.current = playerContext;
10413
10569
  await playerContext.resume();
10570
+
10571
+ // Build the VAD gate before the recorder starts producing frames.
10572
+ // onVoiceFrame streams voiced PCM upstream; onSpeechEnd tells the backend
10573
+ // the utterance is complete so it transcribes + responds. The recorder
10574
+ // runs at 16 kHz (see audio-recorder.js).
10575
+ const sendVoicePcm = pcm => {
10576
+ const ws = websocketRef.current;
10577
+ if (ws && ws.readyState === WebSocket.OPEN) {
10578
+ ws.send(pcm);
10579
+ }
10580
+ };
10581
+ speechGateRef.current = createSpeechGate({
10582
+ sampleRate: 16000
10583
+ }, {
10584
+ onVoiceFrame: sendVoicePcm,
10585
+ onSpeechEnd: () => {
10586
+ const ws = websocketRef.current;
10587
+ if (ws && ws.readyState === WebSocket.OPEN) {
10588
+ ws.send(JSON.stringify({
10589
+ type: "audio_end"
10590
+ }));
10591
+ }
10592
+ }
10593
+ });
10414
10594
  const [recorderNode, recorderContext, micStream] = await startAudioRecorderWorklet(handleRecorderAudio);
10415
10595
  audioRecorderNodeRef.current = recorderNode;
10416
10596
  audioRecorderContextRef.current = recorderContext;