@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.
- package/dist/chat-widget.esm.js +5 -5
- package/dist/chat-widget.js +212 -6
- package/dist/chat-widget.min.js +5 -5
- package/dist/chat-widget.umd.js +5 -5
- package/package.json +1 -1
package/dist/chat-widget.js
CHANGED
|
@@ -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
|
-
|
|
10400
|
-
if (!isVoiceSessionActiveRef.current || !ws || ws.readyState !== WebSocket.OPEN) {
|
|
10551
|
+
if (!isVoiceSessionActiveRef.current) {
|
|
10401
10552
|
return;
|
|
10402
10553
|
}
|
|
10403
|
-
|
|
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$
|
|
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$
|
|
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();
|