@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.
- package/dist/chat-widget.esm.js +5 -5
- package/dist/chat-widget.js +184 -4
- 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,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
|
-
|
|
10400
|
-
|
|
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.
|
|
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;
|