@smooai/chat-widget 0.12.0 → 0.14.0
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/README.md +1 -0
- package/dist/chat-widget.global.js +676 -50
- package/dist/chat-widget.global.js.map +1 -1
- package/dist/index.d.ts +268 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +703 -51
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +29 -1
- package/src/conversation.ts +116 -2
- package/src/element.ts +252 -15
- package/src/index.ts +21 -1
- package/src/styles.ts +83 -0
- package/src/voice-session.ts +420 -0
package/src/index.ts
CHANGED
|
@@ -26,7 +26,25 @@ export {
|
|
|
26
26
|
mountChatWidget,
|
|
27
27
|
mountFullPageChat,
|
|
28
28
|
} from './element.js';
|
|
29
|
-
export type { ChatWidgetConfig, ChatWidgetMode, ChatWidgetTheme } from './config.js';
|
|
29
|
+
export type { ChatWidgetConfig, ChatWidgetMode, ChatWidgetTheme, ChatWidgetVoiceConfig } from './config.js';
|
|
30
|
+
// Browser voice input/output (SMOODEV-2534) — mic capture + TTS playback over
|
|
31
|
+
// the browser-voice WS. Gated by `voice.enabled` (OFF by default).
|
|
32
|
+
export {
|
|
33
|
+
DEFAULT_VOICE_URL,
|
|
34
|
+
downsampleTo16k,
|
|
35
|
+
PcmPlayer,
|
|
36
|
+
rmsLevel,
|
|
37
|
+
VOICE_SAMPLE_RATE,
|
|
38
|
+
VoiceSession,
|
|
39
|
+
type PlayerAudioContext,
|
|
40
|
+
type StartCapture,
|
|
41
|
+
type VoicePlayer,
|
|
42
|
+
type VoiceSessionEvents,
|
|
43
|
+
type VoiceSessionOptions,
|
|
44
|
+
type VoiceSessionSeams,
|
|
45
|
+
type VoiceSessionState,
|
|
46
|
+
type VoiceWebSocket,
|
|
47
|
+
} from './voice-session.js';
|
|
30
48
|
// The deferred loader (also shipped as the `./loader` IIFE for `<script>` embeds):
|
|
31
49
|
// installs the idle/intent/fallback scheduler that lazily injects the widget
|
|
32
50
|
// module so it never competes with the host page's LCP/TBT.
|
|
@@ -39,8 +57,10 @@ export {
|
|
|
39
57
|
type ConnectionStatus,
|
|
40
58
|
type ConversationEvents,
|
|
41
59
|
type IdentityRestore,
|
|
60
|
+
type MessageBlock,
|
|
42
61
|
type RestorableConversation,
|
|
43
62
|
type Role,
|
|
63
|
+
type ToolCall,
|
|
44
64
|
type UserInfo,
|
|
45
65
|
} from './conversation.js';
|
|
46
66
|
export {
|
package/src/styles.ts
CHANGED
|
@@ -29,6 +29,7 @@ export function buildStyles(theme: ResolvedTheme, mode: ChatWidgetMode = 'popove
|
|
|
29
29
|
--sac-bg: ${theme.background};
|
|
30
30
|
--sac-primary: ${theme.primary};
|
|
31
31
|
--sac-primary-text: ${theme.primaryText};
|
|
32
|
+
--sac-secondary: ${theme.secondary};
|
|
32
33
|
--sac-assistant-bubble: ${theme.assistantBubble};
|
|
33
34
|
--sac-assistant-bubble-text: ${theme.assistantBubbleText};
|
|
34
35
|
--sac-user-bubble: ${theme.userBubble};
|
|
@@ -349,6 +350,47 @@ ${
|
|
|
349
350
|
}
|
|
350
351
|
@keyframes sac-blink { to { opacity: 0 } }
|
|
351
352
|
|
|
353
|
+
/* Interleaved tool-activity strip (gated by showToolActivity): prose bubbles
|
|
354
|
+
and inline tool chips stacked in the order the model produced them. */
|
|
355
|
+
.blocks { display: flex; flex-direction: column; align-items: flex-start; gap: 7px; }
|
|
356
|
+
.blocks .bubble { align-self: flex-start; }
|
|
357
|
+
.toolchip {
|
|
358
|
+
display: inline-flex;
|
|
359
|
+
align-items: center;
|
|
360
|
+
gap: 6px;
|
|
361
|
+
max-width: 100%;
|
|
362
|
+
padding: 5px 10px;
|
|
363
|
+
border-radius: 99px;
|
|
364
|
+
font-size: 12px;
|
|
365
|
+
line-height: 1.3;
|
|
366
|
+
color: color-mix(in srgb, var(--sac-text) 78%, transparent);
|
|
367
|
+
background: color-mix(in srgb, var(--sac-text) 6%, transparent);
|
|
368
|
+
border: 1px solid color-mix(in srgb, var(--sac-text) 12%, transparent);
|
|
369
|
+
animation: sac-msg-in .3s var(--sac-ease) both;
|
|
370
|
+
}
|
|
371
|
+
.toolchip .ti { display: inline-flex; flex: none; opacity: .8; }
|
|
372
|
+
.toolchip .ti svg { width: 13px; height: 13px; }
|
|
373
|
+
.toolchip .tn { font-weight: 600; letter-spacing: .01em; }
|
|
374
|
+
.toolchip .ts { opacity: .7; }
|
|
375
|
+
.toolchip .ta {
|
|
376
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
377
|
+
font-size: 11px;
|
|
378
|
+
opacity: .6;
|
|
379
|
+
overflow: hidden;
|
|
380
|
+
text-overflow: ellipsis;
|
|
381
|
+
white-space: nowrap;
|
|
382
|
+
min-width: 0;
|
|
383
|
+
}
|
|
384
|
+
.toolchip.running { border-color: color-mix(in srgb, var(--sac-primary) 45%, transparent); }
|
|
385
|
+
.toolchip.running .ts { color: var(--sac-primary); opacity: 1; animation: sac-typing 1.3s ease-in-out infinite; }
|
|
386
|
+
.toolchip.done .ts::before { content: '✓ '; }
|
|
387
|
+
.toolchip.error {
|
|
388
|
+
color: var(--sac-secondary);
|
|
389
|
+
border-color: color-mix(in srgb, var(--sac-secondary) 50%, transparent);
|
|
390
|
+
background: color-mix(in srgb, var(--sac-secondary) 10%, transparent);
|
|
391
|
+
}
|
|
392
|
+
.toolchip.error .ts::before { content: '! '; }
|
|
393
|
+
|
|
352
394
|
/* ─────────────── Rendered markdown (assistant bubbles / snippets) ─────────── */
|
|
353
395
|
/* The renderer (markdown.ts) emits a small allowlisted set of tags; these rules
|
|
354
396
|
keep them legible inside the tight Aurora-Glass bubble + citation card. */
|
|
@@ -504,6 +546,47 @@ ${
|
|
|
504
546
|
.send:hover { transform: translateY(-1px) scale(1.05); }
|
|
505
547
|
.send:active { transform: scale(.94); }
|
|
506
548
|
.send:disabled { opacity: .4; cursor: default; transform: none; box-shadow: none; }
|
|
549
|
+
|
|
550
|
+
/* Voice mic toggle (SMOODEV-2534) — ghost twin of .send; lights up while live. */
|
|
551
|
+
.mic {
|
|
552
|
+
width: 38px; height: 38px;
|
|
553
|
+
flex: none;
|
|
554
|
+
border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent);
|
|
555
|
+
border-radius: 13px;
|
|
556
|
+
cursor: pointer;
|
|
557
|
+
display: flex;
|
|
558
|
+
align-items: center;
|
|
559
|
+
justify-content: center;
|
|
560
|
+
background: transparent;
|
|
561
|
+
color: color-mix(in srgb, var(--sac-text) 65%, transparent);
|
|
562
|
+
transition: transform .2s var(--sac-ease), color .2s ease, background .25s ease, box-shadow .25s ease;
|
|
563
|
+
}
|
|
564
|
+
.mic svg { width: 18px; height: 18px; }
|
|
565
|
+
.mic:hover { color: var(--sac-text); transform: translateY(-1px) scale(1.05); }
|
|
566
|
+
.mic:active { transform: scale(.94); }
|
|
567
|
+
.mic.active {
|
|
568
|
+
border-color: transparent;
|
|
569
|
+
background: linear-gradient(150deg, var(--sac-primary), var(--sac-primary-2));
|
|
570
|
+
color: var(--sac-primary-text);
|
|
571
|
+
animation: sac-mic-pulse 1.6s ease-out infinite;
|
|
572
|
+
}
|
|
573
|
+
/* Listening → soft expanding ring off the button (mirrors the presence pulse). */
|
|
574
|
+
@keyframes sac-mic-pulse {
|
|
575
|
+
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--sac-primary) 45%, transparent); }
|
|
576
|
+
70% { box-shadow: 0 0 0 9px color-mix(in srgb, var(--sac-primary) 0%, transparent); }
|
|
577
|
+
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--sac-primary) 0%, transparent); }
|
|
578
|
+
}
|
|
579
|
+
/* Agent TTS playing → faster secondary-accent shimmer so "speaking" reads distinctly. */
|
|
580
|
+
.mic.active.speaking {
|
|
581
|
+
animation: sac-mic-speaking 0.9s ease-in-out infinite;
|
|
582
|
+
}
|
|
583
|
+
@keyframes sac-mic-speaking {
|
|
584
|
+
0%, 100% { box-shadow: 0 0 0 2px color-mix(in srgb, var(--sac-secondary) 35%, transparent); }
|
|
585
|
+
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--sac-secondary) 12%, transparent); }
|
|
586
|
+
}
|
|
587
|
+
@media (prefers-reduced-motion: reduce) {
|
|
588
|
+
.mic.active, .mic.active.speaking { animation: none; box-shadow: 0 0 0 2px color-mix(in srgb, var(--sac-primary) 40%, transparent); }
|
|
589
|
+
}
|
|
507
590
|
.footer {
|
|
508
591
|
text-align: center;
|
|
509
592
|
margin-top: 9px;
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VoiceSession — framework-free browser voice for the chat widget.
|
|
3
|
+
*
|
|
4
|
+
* Speaks the FROZEN browser-voice WebSocket protocol (SMOODEV-2534 / ADR-084):
|
|
5
|
+
*
|
|
6
|
+
* client → server:
|
|
7
|
+
* - first frame, JSON: `{"type":"start","agent_id":"…","conversation_id":"…?","token":"…?"}`
|
|
8
|
+
* (public-agent auth is the `Origin` header the browser sends automatically;
|
|
9
|
+
* `token` only for authenticated contexts)
|
|
10
|
+
* - binary frames: raw PCM linear16 mono @ 16 kHz mic chunks
|
|
11
|
+
* - JSON `{"type":"interrupt"}` (user barged in), `{"type":"stop"}`
|
|
12
|
+
* server → client:
|
|
13
|
+
* - JSON `transcript_partial` / `transcript_final` / `reply_text` /
|
|
14
|
+
* `speaking_started` / `speaking_done` / `handoff` / `error {code}`
|
|
15
|
+
* - binary frames: PCM linear16 mono @ 16 kHz TTS audio chunks
|
|
16
|
+
*
|
|
17
|
+
* The class owns: the WS lifecycle, mic capture (getUserMedia → AudioWorklet or
|
|
18
|
+
* ScriptProcessor fallback → downsample to 16 kHz mono Int16 → binary frames),
|
|
19
|
+
* gapless playback of incoming PCM through a 16 kHz AudioContext, and barge-in
|
|
20
|
+
* (RMS speech detection while the agent is speaking → `interrupt` + playback
|
|
21
|
+
* flush). Browser audio + WebSocket are thin injectable seams so unit tests run
|
|
22
|
+
* under jsdom/happy-dom with no real audio.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_VOICE_URL = 'wss://twilio-voice.smoo.ai/browser-voice/ws';
|
|
26
|
+
|
|
27
|
+
/** Target wire format: PCM linear16 mono @ 16 kHz. */
|
|
28
|
+
export const VOICE_SAMPLE_RATE = 16000;
|
|
29
|
+
|
|
30
|
+
// ─────────────────────────── Pure DSP helpers ───────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Downsample Float32 mic samples at `inputRate` to 16 kHz mono Int16 (linear16).
|
|
34
|
+
* Bucket-averaging decimation: each output sample averages its source bucket,
|
|
35
|
+
* which is a cheap low-pass that's plenty for speech (and handles non-integer
|
|
36
|
+
* ratios like 44.1k → 16k). Values are clamped to the Int16 range.
|
|
37
|
+
*/
|
|
38
|
+
export function downsampleTo16k(samples: Float32Array, inputRate: number): Int16Array {
|
|
39
|
+
const ratio = inputRate / VOICE_SAMPLE_RATE;
|
|
40
|
+
const outLen = ratio <= 1 ? samples.length : Math.floor(samples.length / ratio);
|
|
41
|
+
const out = new Int16Array(outLen);
|
|
42
|
+
for (let i = 0; i < outLen; i++) {
|
|
43
|
+
let v: number;
|
|
44
|
+
if (ratio <= 1) {
|
|
45
|
+
v = samples[i]!;
|
|
46
|
+
} else {
|
|
47
|
+
const start = Math.floor(i * ratio);
|
|
48
|
+
const end = Math.min(samples.length, Math.max(start + 1, Math.floor((i + 1) * ratio)));
|
|
49
|
+
let sum = 0;
|
|
50
|
+
for (let j = start; j < end; j++) sum += samples[j]!;
|
|
51
|
+
v = sum / (end - start);
|
|
52
|
+
}
|
|
53
|
+
out[i] = Math.max(-32768, Math.min(32767, Math.round(v * 32767)));
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Root-mean-square level of a Float32 frame (0..1) — the barge-in speech gate. */
|
|
59
|
+
export function rmsLevel(samples: Float32Array): number {
|
|
60
|
+
if (samples.length === 0) return 0;
|
|
61
|
+
let sum = 0;
|
|
62
|
+
for (let i = 0; i < samples.length; i++) sum += samples[i]! * samples[i]!;
|
|
63
|
+
return Math.sqrt(sum / samples.length);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ──────────────────────────── Injectable seams ──────────────────────────────
|
|
67
|
+
|
|
68
|
+
/** The subset of WebSocket the session needs (mockable in tests). */
|
|
69
|
+
export interface VoiceWebSocket {
|
|
70
|
+
binaryType: string;
|
|
71
|
+
readyState: number;
|
|
72
|
+
send(data: string | ArrayBuffer): void;
|
|
73
|
+
close(code?: number, reason?: string): void;
|
|
74
|
+
addEventListener(type: 'open' | 'message' | 'close' | 'error', fn: (ev: never) => void): void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Playback sink for incoming 16 kHz PCM chunks (mockable in tests). */
|
|
78
|
+
export interface VoicePlayer {
|
|
79
|
+
/** Queue a linear16 chunk for gapless playback. */
|
|
80
|
+
enqueue(chunk: ArrayBuffer): void;
|
|
81
|
+
/** Stop everything queued/playing immediately (barge-in). */
|
|
82
|
+
flush(): void;
|
|
83
|
+
/** Flush + release the audio device. */
|
|
84
|
+
close(): void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Start mic capture, invoking `onFrame(samples, sampleRate)` with raw Float32
|
|
89
|
+
* frames until the returned stop function is called.
|
|
90
|
+
*/
|
|
91
|
+
export type StartCapture = (onFrame: (samples: Float32Array, sampleRate: number) => void) => Promise<() => void>;
|
|
92
|
+
|
|
93
|
+
export interface VoiceSessionSeams {
|
|
94
|
+
createWebSocket?: (url: string) => VoiceWebSocket;
|
|
95
|
+
startCapture?: StartCapture;
|
|
96
|
+
createPlayer?: () => VoicePlayer;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─────────────────────── Default (real-browser) seams ───────────────────────
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The minimal AudioContext surface {@link PcmPlayer} uses — injectable so the
|
|
103
|
+
* scheduling/flush logic is unit-testable without real audio hardware.
|
|
104
|
+
*/
|
|
105
|
+
export interface PlayerAudioContext {
|
|
106
|
+
readonly currentTime: number;
|
|
107
|
+
readonly destination: unknown;
|
|
108
|
+
createBuffer(channels: number, length: number, sampleRate: number): { getChannelData(channel: number): Float32Array };
|
|
109
|
+
createBufferSource(): {
|
|
110
|
+
buffer: unknown;
|
|
111
|
+
connect(dest: unknown): void;
|
|
112
|
+
start(when?: number): void;
|
|
113
|
+
stop(): void;
|
|
114
|
+
onended: (() => void) | null;
|
|
115
|
+
};
|
|
116
|
+
close?(): Promise<void>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Gapless PCM playback: each incoming linear16 chunk becomes an AudioBuffer
|
|
121
|
+
* scheduled back-to-back (`nextTime`) so consecutive chunks butt together with
|
|
122
|
+
* no gaps. `flush()` stops every scheduled source (barge-in / mic-button stop).
|
|
123
|
+
*/
|
|
124
|
+
export class PcmPlayer implements VoicePlayer {
|
|
125
|
+
private nextTime = 0;
|
|
126
|
+
private readonly live = new Set<ReturnType<PlayerAudioContext['createBufferSource']>>();
|
|
127
|
+
|
|
128
|
+
constructor(private readonly ctx: PlayerAudioContext) {}
|
|
129
|
+
|
|
130
|
+
enqueue(chunk: ArrayBuffer): void {
|
|
131
|
+
const int16 = new Int16Array(chunk);
|
|
132
|
+
if (int16.length === 0) return;
|
|
133
|
+
const buf = this.ctx.createBuffer(1, int16.length, VOICE_SAMPLE_RATE);
|
|
134
|
+
const ch = buf.getChannelData(0);
|
|
135
|
+
for (let i = 0; i < int16.length; i++) ch[i] = int16[i]! / 32768;
|
|
136
|
+
const src = this.ctx.createBufferSource();
|
|
137
|
+
src.buffer = buf;
|
|
138
|
+
src.connect(this.ctx.destination);
|
|
139
|
+
const start = Math.max(this.ctx.currentTime, this.nextTime);
|
|
140
|
+
src.start(start);
|
|
141
|
+
this.nextTime = start + int16.length / VOICE_SAMPLE_RATE;
|
|
142
|
+
this.live.add(src);
|
|
143
|
+
src.onended = () => this.live.delete(src);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
flush(): void {
|
|
147
|
+
for (const src of this.live) {
|
|
148
|
+
try {
|
|
149
|
+
src.stop();
|
|
150
|
+
} catch {
|
|
151
|
+
/* already stopped */
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
this.live.clear();
|
|
155
|
+
this.nextTime = 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
close(): void {
|
|
159
|
+
this.flush();
|
|
160
|
+
void this.ctx.close?.();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** AudioWorklet processor source — posts each 128-sample mic block to the main thread. */
|
|
165
|
+
const CAPTURE_WORKLET_SRC = `
|
|
166
|
+
registerProcessor('sac-mic-capture', class extends AudioWorkletProcessor {
|
|
167
|
+
process(inputs) {
|
|
168
|
+
const ch = inputs[0] && inputs[0][0];
|
|
169
|
+
if (ch && ch.length > 0) {
|
|
170
|
+
const copy = new Float32Array(ch);
|
|
171
|
+
this.port.postMessage(copy.buffer, [copy.buffer]);
|
|
172
|
+
}
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
`;
|
|
177
|
+
|
|
178
|
+
/** Real mic capture: getUserMedia → AudioWorklet (or ScriptProcessor fallback). */
|
|
179
|
+
const defaultStartCapture: StartCapture = async (onFrame) => {
|
|
180
|
+
const stream = await navigator.mediaDevices.getUserMedia({
|
|
181
|
+
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
|
182
|
+
});
|
|
183
|
+
const Ctx = (globalThis as { AudioContext?: typeof AudioContext }).AudioContext;
|
|
184
|
+
if (!Ctx) {
|
|
185
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
186
|
+
throw new Error('AudioContext is not available');
|
|
187
|
+
}
|
|
188
|
+
const ctx = new Ctx();
|
|
189
|
+
const sampleRate = ctx.sampleRate;
|
|
190
|
+
const source = ctx.createMediaStreamSource(stream);
|
|
191
|
+
let disconnectNode: () => void;
|
|
192
|
+
if (ctx.audioWorklet && typeof AudioWorkletNode === 'function') {
|
|
193
|
+
const url = URL.createObjectURL(new Blob([CAPTURE_WORKLET_SRC], { type: 'text/javascript' }));
|
|
194
|
+
try {
|
|
195
|
+
await ctx.audioWorklet.addModule(url);
|
|
196
|
+
} finally {
|
|
197
|
+
URL.revokeObjectURL(url);
|
|
198
|
+
}
|
|
199
|
+
const node = new AudioWorkletNode(ctx, 'sac-mic-capture');
|
|
200
|
+
node.port.onmessage = (ev: MessageEvent<ArrayBuffer>) => onFrame(new Float32Array(ev.data), sampleRate);
|
|
201
|
+
source.connect(node);
|
|
202
|
+
disconnectNode = () => node.disconnect();
|
|
203
|
+
} else {
|
|
204
|
+
// ponytail: deprecated ScriptProcessor fallback for browsers without AudioWorklet
|
|
205
|
+
const node = ctx.createScriptProcessor(4096, 1, 1);
|
|
206
|
+
node.onaudioprocess = (ev) => onFrame(new Float32Array(ev.inputBuffer.getChannelData(0)), sampleRate);
|
|
207
|
+
source.connect(node);
|
|
208
|
+
node.connect(ctx.destination); // ScriptProcessor only fires while connected
|
|
209
|
+
disconnectNode = () => node.disconnect();
|
|
210
|
+
}
|
|
211
|
+
return () => {
|
|
212
|
+
disconnectNode();
|
|
213
|
+
source.disconnect();
|
|
214
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
215
|
+
void ctx.close();
|
|
216
|
+
};
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const defaultCreatePlayer = (): VoicePlayer => {
|
|
220
|
+
const Ctx = (globalThis as { AudioContext?: new (opts?: { sampleRate?: number }) => AudioContext }).AudioContext;
|
|
221
|
+
if (!Ctx) throw new Error('AudioContext is not available');
|
|
222
|
+
return new PcmPlayer(new Ctx({ sampleRate: VOICE_SAMPLE_RATE }) as unknown as PlayerAudioContext);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// ────────────────────────────── VoiceSession ────────────────────────────────
|
|
226
|
+
|
|
227
|
+
export interface VoiceSessionOptions {
|
|
228
|
+
/** Full browser-voice WS endpoint (default {@link DEFAULT_VOICE_URL}). */
|
|
229
|
+
url?: string;
|
|
230
|
+
/** UUID of the agent to talk to. */
|
|
231
|
+
agentId: string;
|
|
232
|
+
/** Existing conversation id so voice resumes the same thread. */
|
|
233
|
+
conversationId?: string;
|
|
234
|
+
/** Optional JWT for authenticated contexts (public agents auth by Origin). */
|
|
235
|
+
token?: string;
|
|
236
|
+
/**
|
|
237
|
+
* RMS level (0..1) above which a mic frame counts as speech for barge-in
|
|
238
|
+
* while the agent is speaking. Default 0.02 — comfortably above room noise
|
|
239
|
+
* post-AGC, well below speech.
|
|
240
|
+
*/
|
|
241
|
+
bargeInThreshold?: number;
|
|
242
|
+
/** Injectable browser seams (tests). */
|
|
243
|
+
seams?: VoiceSessionSeams;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export interface VoiceSessionEvents {
|
|
247
|
+
onTranscriptPartial?: (text: string) => void;
|
|
248
|
+
onTranscriptFinal?: (text: string) => void;
|
|
249
|
+
onReplyText?: (text: string) => void;
|
|
250
|
+
/** Agent TTS started/stopped playing. */
|
|
251
|
+
onSpeaking?: (speaking: boolean) => void;
|
|
252
|
+
onError?: (code: string) => void;
|
|
253
|
+
/** The session is over (stop, server close, handoff, or error). Fires once. */
|
|
254
|
+
onEnded?: () => void;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export type VoiceSessionState = 'idle' | 'connecting' | 'active' | 'ended';
|
|
258
|
+
|
|
259
|
+
export class VoiceSession {
|
|
260
|
+
private readonly opts: VoiceSessionOptions;
|
|
261
|
+
private readonly events: VoiceSessionEvents;
|
|
262
|
+
private ws: VoiceWebSocket | null = null;
|
|
263
|
+
private player: VoicePlayer | null = null;
|
|
264
|
+
private stopCapture: (() => void) | null = null;
|
|
265
|
+
private speaking = false;
|
|
266
|
+
private ended = false;
|
|
267
|
+
state: VoiceSessionState = 'idle';
|
|
268
|
+
|
|
269
|
+
constructor(opts: VoiceSessionOptions, events: VoiceSessionEvents = {}) {
|
|
270
|
+
this.opts = opts;
|
|
271
|
+
this.events = events;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Open the WS, send the start frame, and begin streaming mic audio. */
|
|
275
|
+
async start(): Promise<void> {
|
|
276
|
+
if (this.state !== 'idle') return;
|
|
277
|
+
this.state = 'connecting';
|
|
278
|
+
const url = this.opts.url ?? DEFAULT_VOICE_URL;
|
|
279
|
+
const createWs = this.opts.seams?.createWebSocket ?? ((u: string) => new WebSocket(u) as unknown as VoiceWebSocket);
|
|
280
|
+
const startCapture = this.opts.seams?.startCapture ?? defaultStartCapture;
|
|
281
|
+
const createPlayer = this.opts.seams?.createPlayer ?? defaultCreatePlayer;
|
|
282
|
+
|
|
283
|
+
// Mic permission FIRST — if the visitor denies it, no socket ever opens.
|
|
284
|
+
this.stopCapture = await startCapture((samples, rate) => this.handleMicFrame(samples, rate));
|
|
285
|
+
try {
|
|
286
|
+
this.player = createPlayer();
|
|
287
|
+
const ws = createWs(url);
|
|
288
|
+
ws.binaryType = 'arraybuffer';
|
|
289
|
+
this.ws = ws;
|
|
290
|
+
ws.addEventListener('open', () => {
|
|
291
|
+
const start: Record<string, unknown> = { type: 'start', agent_id: this.opts.agentId };
|
|
292
|
+
if (this.opts.conversationId) start.conversation_id = this.opts.conversationId;
|
|
293
|
+
if (this.opts.token) start.token = this.opts.token;
|
|
294
|
+
ws.send(JSON.stringify(start));
|
|
295
|
+
this.state = 'active';
|
|
296
|
+
});
|
|
297
|
+
ws.addEventListener('message', (ev: MessageEvent) => this.handleServerFrame(ev.data as string | ArrayBuffer));
|
|
298
|
+
ws.addEventListener('close', () => this.teardown());
|
|
299
|
+
ws.addEventListener('error', () => {
|
|
300
|
+
this.events.onError?.('connection_error');
|
|
301
|
+
this.teardown();
|
|
302
|
+
});
|
|
303
|
+
} catch (err) {
|
|
304
|
+
this.teardown();
|
|
305
|
+
throw err;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** One raw mic frame: barge-in check, then downsample → binary frame. */
|
|
310
|
+
private handleMicFrame(samples: Float32Array, sampleRate: number): void {
|
|
311
|
+
const ws = this.ws;
|
|
312
|
+
if (!ws || ws.readyState !== 1 /* OPEN */ || this.state !== 'active') return;
|
|
313
|
+
// Barge-in: the visitor speaking over the agent's TTS interrupts it.
|
|
314
|
+
if (this.speaking && rmsLevel(samples) > (this.opts.bargeInThreshold ?? 0.02)) {
|
|
315
|
+
this.interrupt();
|
|
316
|
+
}
|
|
317
|
+
const pcm = downsampleTo16k(samples, sampleRate);
|
|
318
|
+
ws.send(pcm.buffer as ArrayBuffer);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Route a server frame: binary = TTS audio, string = JSON control event. */
|
|
322
|
+
private handleServerFrame(data: string | ArrayBuffer): void {
|
|
323
|
+
if (typeof data !== 'string') {
|
|
324
|
+
this.player?.enqueue(data);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
let msg: { type?: string; text?: string; code?: string };
|
|
328
|
+
try {
|
|
329
|
+
msg = JSON.parse(data) as typeof msg;
|
|
330
|
+
} catch {
|
|
331
|
+
return; // not ours — ignore
|
|
332
|
+
}
|
|
333
|
+
switch (msg.type) {
|
|
334
|
+
case 'transcript_partial':
|
|
335
|
+
this.events.onTranscriptPartial?.(msg.text ?? '');
|
|
336
|
+
break;
|
|
337
|
+
case 'transcript_final':
|
|
338
|
+
this.events.onTranscriptFinal?.(msg.text ?? '');
|
|
339
|
+
break;
|
|
340
|
+
case 'reply_text':
|
|
341
|
+
this.events.onReplyText?.(msg.text ?? '');
|
|
342
|
+
break;
|
|
343
|
+
case 'speaking_started':
|
|
344
|
+
this.setSpeaking(true);
|
|
345
|
+
break;
|
|
346
|
+
case 'speaking_done':
|
|
347
|
+
this.setSpeaking(false);
|
|
348
|
+
break;
|
|
349
|
+
case 'handoff':
|
|
350
|
+
// The agent handed the caller off — voice is over; back to text.
|
|
351
|
+
this.stop();
|
|
352
|
+
break;
|
|
353
|
+
case 'error':
|
|
354
|
+
this.events.onError?.(msg.code ?? 'unknown');
|
|
355
|
+
this.stop();
|
|
356
|
+
break;
|
|
357
|
+
default:
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private setSpeaking(speaking: boolean): void {
|
|
363
|
+
if (this.speaking === speaking) return;
|
|
364
|
+
this.speaking = speaking;
|
|
365
|
+
this.events.onSpeaking?.(speaking);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** True while agent TTS is playing (between speaking_started/done). */
|
|
369
|
+
get isSpeaking(): boolean {
|
|
370
|
+
return this.speaking;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Barge in: tell the server the user interrupted and flush queued TTS so the
|
|
375
|
+
* agent goes silent immediately. Called automatically on mic speech during
|
|
376
|
+
* playback; the widget also calls it when the visitor hits the mic button
|
|
377
|
+
* mid-playback.
|
|
378
|
+
*/
|
|
379
|
+
interrupt(): void {
|
|
380
|
+
if (this.ws && this.ws.readyState === 1) {
|
|
381
|
+
this.ws.send(JSON.stringify({ type: 'interrupt' }));
|
|
382
|
+
}
|
|
383
|
+
this.player?.flush();
|
|
384
|
+
this.setSpeaking(false);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Graceful end: send `stop`, then tear everything down. */
|
|
388
|
+
stop(): void {
|
|
389
|
+
if (this.ws && this.ws.readyState === 1) {
|
|
390
|
+
try {
|
|
391
|
+
this.ws.send(JSON.stringify({ type: 'stop' }));
|
|
392
|
+
} catch {
|
|
393
|
+
/* socket already going down */
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
this.teardown();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Idempotent teardown: capture, playback, socket, `ended` event. */
|
|
400
|
+
private teardown(): void {
|
|
401
|
+
if (this.ended) return;
|
|
402
|
+
this.ended = true;
|
|
403
|
+
this.state = 'ended';
|
|
404
|
+
this.stopCapture?.();
|
|
405
|
+
this.stopCapture = null;
|
|
406
|
+
this.player?.close();
|
|
407
|
+
this.player = null;
|
|
408
|
+
const ws = this.ws;
|
|
409
|
+
this.ws = null;
|
|
410
|
+
if (ws && ws.readyState <= 1) {
|
|
411
|
+
try {
|
|
412
|
+
ws.close(1000, 'voice ended');
|
|
413
|
+
} catch {
|
|
414
|
+
/* already closed */
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
this.setSpeaking(false);
|
|
418
|
+
this.events.onEnded?.();
|
|
419
|
+
}
|
|
420
|
+
}
|