@lokutor/sdk 1.1.17 → 1.1.20
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/index.d.mts +193 -4
- package/dist/index.d.ts +193 -4
- package/dist/index.js +963 -219
- package/dist/index.mjs +960 -63
- package/package.json +13 -4
- package/src/audio-utils.ts +253 -0
- package/src/browser-audio.ts +395 -0
- package/src/client.ts +886 -0
- package/src/conversational-panel.ts +606 -0
- package/src/index.ts +27 -0
- package/src/node-audio.ts +115 -0
- package/src/types.ts +270 -0
- package/dist/chunk-UI24THO7.mjs +0 -44
- package/dist/node-audio-5HOWE6MC.mjs +0 -94
package/dist/index.js
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var __create = Object.create;
|
|
3
2
|
var __defProp = Object.defineProperty;
|
|
4
3
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
4
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
-
var __getProtoOf = Object.getPrototypeOf;
|
|
7
5
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
-
var __esm = (fn, res) => function __init() {
|
|
9
|
-
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
10
|
-
};
|
|
11
6
|
var __export = (target, all) => {
|
|
12
7
|
for (var name in all)
|
|
13
8
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
@@ -20,166 +15,17 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
20
15
|
}
|
|
21
16
|
return to;
|
|
22
17
|
};
|
|
23
|
-
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
24
|
-
// If the importer is in node compatibility mode or this is not an ESM
|
|
25
|
-
// file that has been converted to a CommonJS file using a Babel-
|
|
26
|
-
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
27
|
-
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
28
|
-
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
29
|
-
mod
|
|
30
|
-
));
|
|
31
18
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
32
19
|
|
|
33
|
-
// src/types.ts
|
|
34
|
-
var VoiceStyle, Language, AUDIO_CONFIG, DEFAULT_URLS;
|
|
35
|
-
var init_types = __esm({
|
|
36
|
-
"src/types.ts"() {
|
|
37
|
-
"use strict";
|
|
38
|
-
VoiceStyle = /* @__PURE__ */ ((VoiceStyle2) => {
|
|
39
|
-
VoiceStyle2["F1"] = "F1";
|
|
40
|
-
VoiceStyle2["F2"] = "F2";
|
|
41
|
-
VoiceStyle2["F3"] = "F3";
|
|
42
|
-
VoiceStyle2["F4"] = "F4";
|
|
43
|
-
VoiceStyle2["F5"] = "F5";
|
|
44
|
-
VoiceStyle2["M1"] = "M1";
|
|
45
|
-
VoiceStyle2["M2"] = "M2";
|
|
46
|
-
VoiceStyle2["M3"] = "M3";
|
|
47
|
-
VoiceStyle2["M4"] = "M4";
|
|
48
|
-
VoiceStyle2["M5"] = "M5";
|
|
49
|
-
return VoiceStyle2;
|
|
50
|
-
})(VoiceStyle || {});
|
|
51
|
-
Language = /* @__PURE__ */ ((Language2) => {
|
|
52
|
-
Language2["ENGLISH"] = "en";
|
|
53
|
-
Language2["SPANISH"] = "es";
|
|
54
|
-
Language2["FRENCH"] = "fr";
|
|
55
|
-
Language2["PORTUGUESE"] = "pt";
|
|
56
|
-
Language2["KOREAN"] = "ko";
|
|
57
|
-
return Language2;
|
|
58
|
-
})(Language || {});
|
|
59
|
-
AUDIO_CONFIG = {
|
|
60
|
-
SAMPLE_RATE: 16e3,
|
|
61
|
-
SAMPLE_RATE_INPUT: 16e3,
|
|
62
|
-
SPEAKER_SAMPLE_RATE: 44100,
|
|
63
|
-
SAMPLE_RATE_OUTPUT: 44100,
|
|
64
|
-
CHANNELS: 1,
|
|
65
|
-
CHUNK_DURATION_MS: 20,
|
|
66
|
-
get CHUNK_SIZE() {
|
|
67
|
-
return Math.floor(this.SAMPLE_RATE * this.CHUNK_DURATION_MS / 1e3);
|
|
68
|
-
}
|
|
69
|
-
};
|
|
70
|
-
DEFAULT_URLS = {
|
|
71
|
-
VOICE_AGENT: "wss://api.lokutor.com/ws/agent",
|
|
72
|
-
TTS: "wss://api.lokutor.com/ws/tts"
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
// src/node-audio.ts
|
|
78
|
-
var node_audio_exports = {};
|
|
79
|
-
__export(node_audio_exports, {
|
|
80
|
-
NodeAudioManager: () => NodeAudioManager
|
|
81
|
-
});
|
|
82
|
-
var NodeAudioManager;
|
|
83
|
-
var init_node_audio = __esm({
|
|
84
|
-
"src/node-audio.ts"() {
|
|
85
|
-
"use strict";
|
|
86
|
-
init_types();
|
|
87
|
-
NodeAudioManager = class {
|
|
88
|
-
speaker = null;
|
|
89
|
-
recorder = null;
|
|
90
|
-
recordingStream = null;
|
|
91
|
-
isMuted = false;
|
|
92
|
-
isListening = false;
|
|
93
|
-
constructor() {
|
|
94
|
-
}
|
|
95
|
-
async init() {
|
|
96
|
-
try {
|
|
97
|
-
const Speaker = await import("speaker").catch(() => null);
|
|
98
|
-
if (!Speaker) {
|
|
99
|
-
console.warn('\u26A0\uFE0F Package "speaker" is missing. Hardware output will be disabled.');
|
|
100
|
-
console.warn("\u{1F449} Run: npm install speaker");
|
|
101
|
-
}
|
|
102
|
-
} catch (e) {
|
|
103
|
-
console.error("Error initializing Node audio:", e);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
async startMicrophone(onAudioInput) {
|
|
107
|
-
if (this.isListening) return;
|
|
108
|
-
try {
|
|
109
|
-
const recorder = await import("node-record-lpcm16").catch(() => null);
|
|
110
|
-
if (!recorder) {
|
|
111
|
-
throw new Error('Package "node-record-lpcm16" is missing. Microphone input failed.\n\u{1F449} Run: npm install node-record-lpcm16');
|
|
112
|
-
}
|
|
113
|
-
console.log("\u{1F3A4} Starting microphone (Node.js)...");
|
|
114
|
-
this.recordingStream = recorder.record({
|
|
115
|
-
sampleRate: AUDIO_CONFIG.SAMPLE_RATE,
|
|
116
|
-
threshold: 0,
|
|
117
|
-
verbose: false,
|
|
118
|
-
recordProgram: "sox"
|
|
119
|
-
// default
|
|
120
|
-
});
|
|
121
|
-
this.recordingStream.stream().on("data", (chunk) => {
|
|
122
|
-
if (!this.isMuted && onAudioInput) {
|
|
123
|
-
onAudioInput(new Uint8Array(chunk));
|
|
124
|
-
}
|
|
125
|
-
});
|
|
126
|
-
this.isListening = true;
|
|
127
|
-
} catch (e) {
|
|
128
|
-
console.error("Failed to start microphone:", e.message);
|
|
129
|
-
throw e;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
stopMicrophone() {
|
|
133
|
-
if (this.recordingStream) {
|
|
134
|
-
this.recordingStream.stop();
|
|
135
|
-
this.recordingStream = null;
|
|
136
|
-
}
|
|
137
|
-
this.isListening = false;
|
|
138
|
-
}
|
|
139
|
-
async playAudio(pcm16Data) {
|
|
140
|
-
try {
|
|
141
|
-
if (!this.speaker) {
|
|
142
|
-
const Speaker = (await import("speaker")).default;
|
|
143
|
-
this.speaker = new Speaker({
|
|
144
|
-
channels: AUDIO_CONFIG.CHANNELS,
|
|
145
|
-
bitDepth: 16,
|
|
146
|
-
sampleRate: AUDIO_CONFIG.SPEAKER_SAMPLE_RATE
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
this.speaker.write(Buffer.from(pcm16Data));
|
|
150
|
-
} catch (e) {
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
stopPlayback() {
|
|
154
|
-
if (this.speaker) {
|
|
155
|
-
this.speaker.end();
|
|
156
|
-
this.speaker = null;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
cleanup() {
|
|
160
|
-
this.stopMicrophone();
|
|
161
|
-
this.stopPlayback();
|
|
162
|
-
}
|
|
163
|
-
isMicMuted() {
|
|
164
|
-
return this.isMuted;
|
|
165
|
-
}
|
|
166
|
-
setMuted(muted) {
|
|
167
|
-
this.isMuted = muted;
|
|
168
|
-
}
|
|
169
|
-
getAmplitude() {
|
|
170
|
-
return 0;
|
|
171
|
-
}
|
|
172
|
-
};
|
|
173
|
-
}
|
|
174
|
-
});
|
|
175
|
-
|
|
176
20
|
// src/index.ts
|
|
177
21
|
var index_exports = {};
|
|
178
22
|
__export(index_exports, {
|
|
179
23
|
AUDIO_CONFIG: () => AUDIO_CONFIG,
|
|
180
24
|
BrowserAudioManager: () => BrowserAudioManager,
|
|
25
|
+
ConversationalPanel: () => ConversationalPanel,
|
|
181
26
|
DEFAULT_URLS: () => DEFAULT_URLS,
|
|
182
27
|
Language: () => Language,
|
|
28
|
+
LokutorError: () => LokutorError,
|
|
183
29
|
StreamResampler: () => StreamResampler,
|
|
184
30
|
TTSClient: () => TTSClient,
|
|
185
31
|
VoiceAgentClient: () => VoiceAgentClient,
|
|
@@ -188,6 +34,7 @@ __export(index_exports, {
|
|
|
188
34
|
bytesToPcm16: () => bytesToPcm16,
|
|
189
35
|
calculateRMS: () => calculateRMS,
|
|
190
36
|
float32ToPcm16: () => float32ToPcm16,
|
|
37
|
+
isRetryable: () => isRetryable,
|
|
191
38
|
normalizeAudio: () => normalizeAudio,
|
|
192
39
|
pcm16ToBytes: () => pcm16ToBytes,
|
|
193
40
|
pcm16ToFloat32: () => pcm16ToFloat32,
|
|
@@ -197,13 +44,79 @@ __export(index_exports, {
|
|
|
197
44
|
simpleTTS: () => simpleTTS
|
|
198
45
|
});
|
|
199
46
|
module.exports = __toCommonJS(index_exports);
|
|
200
|
-
init_types();
|
|
201
|
-
|
|
202
|
-
// src/client.ts
|
|
203
|
-
init_types();
|
|
204
47
|
|
|
205
|
-
// src/
|
|
206
|
-
|
|
48
|
+
// src/types.ts
|
|
49
|
+
var VoiceStyle = /* @__PURE__ */ ((VoiceStyle2) => {
|
|
50
|
+
VoiceStyle2["F1"] = "F1";
|
|
51
|
+
VoiceStyle2["F2"] = "F2";
|
|
52
|
+
VoiceStyle2["F3"] = "F3";
|
|
53
|
+
VoiceStyle2["F4"] = "F4";
|
|
54
|
+
VoiceStyle2["F5"] = "F5";
|
|
55
|
+
VoiceStyle2["M1"] = "M1";
|
|
56
|
+
VoiceStyle2["M2"] = "M2";
|
|
57
|
+
VoiceStyle2["M3"] = "M3";
|
|
58
|
+
VoiceStyle2["M4"] = "M4";
|
|
59
|
+
VoiceStyle2["M5"] = "M5";
|
|
60
|
+
return VoiceStyle2;
|
|
61
|
+
})(VoiceStyle || {});
|
|
62
|
+
var Language = /* @__PURE__ */ ((Language2) => {
|
|
63
|
+
Language2["ENGLISH"] = "en";
|
|
64
|
+
Language2["SPANISH"] = "es";
|
|
65
|
+
Language2["FRENCH"] = "fr";
|
|
66
|
+
Language2["PORTUGUESE"] = "pt";
|
|
67
|
+
Language2["KOREAN"] = "ko";
|
|
68
|
+
return Language2;
|
|
69
|
+
})(Language || {});
|
|
70
|
+
var AUDIO_CONFIG = {
|
|
71
|
+
SAMPLE_RATE: 16e3,
|
|
72
|
+
SAMPLE_RATE_INPUT: 16e3,
|
|
73
|
+
SPEAKER_SAMPLE_RATE: 44100,
|
|
74
|
+
SAMPLE_RATE_OUTPUT: 44100,
|
|
75
|
+
CHANNELS: 1,
|
|
76
|
+
CHUNK_DURATION_MS: 20,
|
|
77
|
+
get CHUNK_SIZE() {
|
|
78
|
+
return Math.floor(this.SAMPLE_RATE * this.CHUNK_DURATION_MS / 1e3);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
var DEFAULT_URLS = {
|
|
82
|
+
VOICE_AGENT: "wss://api.lokutor.com/ws/agent",
|
|
83
|
+
TTS: "wss://api.lokutor.com/ws/tts"
|
|
84
|
+
};
|
|
85
|
+
var LokutorError = class extends Error {
|
|
86
|
+
code;
|
|
87
|
+
detail;
|
|
88
|
+
retryable;
|
|
89
|
+
original;
|
|
90
|
+
constructor(code, message, opts) {
|
|
91
|
+
super(message);
|
|
92
|
+
this.name = "LokutorError";
|
|
93
|
+
this.code = code;
|
|
94
|
+
this.detail = opts?.detail;
|
|
95
|
+
this.retryable = opts?.retryable ?? isRetryableCode(code);
|
|
96
|
+
this.original = opts?.original;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
function isRetryableCode(code) {
|
|
100
|
+
const fatal = [
|
|
101
|
+
"auth.missing_key",
|
|
102
|
+
"auth.invalid_key",
|
|
103
|
+
"auth.time_limited",
|
|
104
|
+
"validation.invalid_voice",
|
|
105
|
+
"validation.invalid_language",
|
|
106
|
+
"validation.text_too_long",
|
|
107
|
+
"validation.speed_out_of_range",
|
|
108
|
+
"validation.steps_out_of_range",
|
|
109
|
+
"validation.invalid_request_format",
|
|
110
|
+
"internal.cancelled"
|
|
111
|
+
];
|
|
112
|
+
return !fatal.includes(code);
|
|
113
|
+
}
|
|
114
|
+
function isRetryable(error) {
|
|
115
|
+
if (error instanceof LokutorError) {
|
|
116
|
+
return error.retryable;
|
|
117
|
+
}
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
207
120
|
|
|
208
121
|
// src/audio-utils.ts
|
|
209
122
|
function pcm16ToFloat32(int16Data) {
|
|
@@ -264,6 +177,9 @@ function pcm16ToBytes(data) {
|
|
|
264
177
|
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
265
178
|
}
|
|
266
179
|
function bytesToPcm16(bytes) {
|
|
180
|
+
if (bytes.length % 2 !== 0) {
|
|
181
|
+
bytes = bytes.slice(0, bytes.length - 1);
|
|
182
|
+
}
|
|
267
183
|
return new Int16Array(bytes.buffer, bytes.byteOffset, bytes.length / 2);
|
|
268
184
|
}
|
|
269
185
|
function normalizeAudio(data, targetPeak = 0.95) {
|
|
@@ -313,11 +229,14 @@ var StreamResampler = class {
|
|
|
313
229
|
combined.set(this.inputBuffer);
|
|
314
230
|
combined.set(inputChunk, this.inputBuffer.length);
|
|
315
231
|
const ratio = this.inputRate / this.outputRate;
|
|
316
|
-
|
|
232
|
+
let outputLength = Math.floor(combined.length / ratio);
|
|
317
233
|
if (outputLength === 0 && !flush) {
|
|
318
234
|
this.inputBuffer = combined;
|
|
319
235
|
return new Float32Array(0);
|
|
320
236
|
}
|
|
237
|
+
if (flush && outputLength === 0 && combined.length > 0) {
|
|
238
|
+
outputLength = 1;
|
|
239
|
+
}
|
|
321
240
|
const output = new Float32Array(outputLength);
|
|
322
241
|
for (let i = 0; i < outputLength; i++) {
|
|
323
242
|
const pos = i * ratio;
|
|
@@ -326,10 +245,8 @@ var StreamResampler = class {
|
|
|
326
245
|
const weight = pos - left;
|
|
327
246
|
output[i] = combined[left] * (1 - weight) + combined[right] * weight;
|
|
328
247
|
}
|
|
329
|
-
const
|
|
330
|
-
this.inputBuffer = combined.slice(
|
|
331
|
-
combined.length - remainingSamples
|
|
332
|
-
);
|
|
248
|
+
const consumed = Math.floor(outputLength * ratio);
|
|
249
|
+
this.inputBuffer = combined.slice(consumed);
|
|
333
250
|
return output;
|
|
334
251
|
}
|
|
335
252
|
reset() {
|
|
@@ -491,6 +408,10 @@ var BrowserAudioManager = class {
|
|
|
491
408
|
console.warn("AudioContext not initialized");
|
|
492
409
|
return;
|
|
493
410
|
}
|
|
411
|
+
if (pcm16Data.length % 2 !== 0) {
|
|
412
|
+
console.warn(`Discarding odd-length PCM buffer (${pcm16Data.length} bytes)`);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
494
415
|
const int16Array = new Int16Array(
|
|
495
416
|
pcm16Data.buffer,
|
|
496
417
|
pcm16Data.byteOffset,
|
|
@@ -619,6 +540,54 @@ var BrowserAudioManager = class {
|
|
|
619
540
|
};
|
|
620
541
|
|
|
621
542
|
// src/client.ts
|
|
543
|
+
function sdkTraceEnabled() {
|
|
544
|
+
try {
|
|
545
|
+
if (typeof window === "undefined") return false;
|
|
546
|
+
const w = window;
|
|
547
|
+
return Boolean(w.LOKUTOR_TRACE) || window.localStorage?.getItem("lokutorTrace") === "1";
|
|
548
|
+
} catch {
|
|
549
|
+
return false;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
function sdkTrace(...args) {
|
|
553
|
+
if (sdkTraceEnabled()) {
|
|
554
|
+
console.log("[SDK TRACE]", ...args);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
function nowMs() {
|
|
558
|
+
if (typeof performance !== "undefined" && performance.now) {
|
|
559
|
+
return performance.now();
|
|
560
|
+
}
|
|
561
|
+
return Date.now();
|
|
562
|
+
}
|
|
563
|
+
function wsToHttp(url) {
|
|
564
|
+
return url.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
|
|
565
|
+
}
|
|
566
|
+
async function fetchJson(url, timeoutMs = 1e4) {
|
|
567
|
+
const controller = new AbortController();
|
|
568
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
569
|
+
try {
|
|
570
|
+
const res = await fetch(url, {
|
|
571
|
+
signal: controller.signal,
|
|
572
|
+
headers: { Accept: "application/json" }
|
|
573
|
+
});
|
|
574
|
+
clearTimeout(timer);
|
|
575
|
+
if (!res.ok) {
|
|
576
|
+
throw new LokutorError("internal.error", `HTTP ${res.status} from ${url}`, {
|
|
577
|
+
detail: await res.text().catch(() => ""),
|
|
578
|
+
retryable: res.status >= 500
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
return await res.json();
|
|
582
|
+
} catch (err) {
|
|
583
|
+
clearTimeout(timer);
|
|
584
|
+
if (err instanceof LokutorError) throw err;
|
|
585
|
+
throw new LokutorError("internal.error", `Failed to fetch ${url}`, {
|
|
586
|
+
original: err,
|
|
587
|
+
retryable: true
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
}
|
|
622
591
|
function base64ToUint8Array(base64) {
|
|
623
592
|
const binaryString = atob(base64);
|
|
624
593
|
const bytes = new Uint8Array(binaryString.length);
|
|
@@ -627,6 +596,39 @@ function base64ToUint8Array(base64) {
|
|
|
627
596
|
}
|
|
628
597
|
return bytes;
|
|
629
598
|
}
|
|
599
|
+
function normalizeVisemes(payload) {
|
|
600
|
+
if (!Array.isArray(payload)) return [];
|
|
601
|
+
const normalized = [];
|
|
602
|
+
for (const item of payload) {
|
|
603
|
+
if (!item || typeof item !== "object") continue;
|
|
604
|
+
const c = String(item.c ?? item.char ?? "sil").toLowerCase();
|
|
605
|
+
const t = Number(item.t ?? item.timestamp ?? 0);
|
|
606
|
+
const v = Number(item.v ?? item.id ?? 0);
|
|
607
|
+
normalized.push({
|
|
608
|
+
v: Number.isFinite(v) ? v : 0,
|
|
609
|
+
c,
|
|
610
|
+
t: Number.isFinite(t) ? t : 0
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
return normalized;
|
|
614
|
+
}
|
|
615
|
+
function extractVisemePayload(msg) {
|
|
616
|
+
if (Array.isArray(msg?.data)) {
|
|
617
|
+
return normalizeVisemes(msg.data);
|
|
618
|
+
}
|
|
619
|
+
if (Array.isArray(msg?.data?.visemes)) {
|
|
620
|
+
return normalizeVisemes(msg.data.visemes);
|
|
621
|
+
}
|
|
622
|
+
if (msg?.data && !Array.isArray(msg.data) && typeof msg.data === "object") {
|
|
623
|
+
const singularInData = normalizeVisemes([msg.data]);
|
|
624
|
+
if (singularInData.length > 0) return singularInData;
|
|
625
|
+
}
|
|
626
|
+
if (msg && !Array.isArray(msg) && typeof msg === "object") {
|
|
627
|
+
const singularAtRoot = normalizeVisemes([msg]);
|
|
628
|
+
if (singularAtRoot.length > 0) return singularAtRoot;
|
|
629
|
+
}
|
|
630
|
+
return [];
|
|
631
|
+
}
|
|
630
632
|
var VoiceAgentClient = class {
|
|
631
633
|
ws = null;
|
|
632
634
|
apiKey;
|
|
@@ -654,11 +656,13 @@ var VoiceAgentClient = class {
|
|
|
654
656
|
reconnecting = false;
|
|
655
657
|
reconnectAttempts = 0;
|
|
656
658
|
maxReconnectAttempts = 5;
|
|
659
|
+
serverUrl;
|
|
657
660
|
constructor(config) {
|
|
658
661
|
this.apiKey = config.apiKey;
|
|
659
662
|
this.prompt = config.prompt;
|
|
660
663
|
this.voice = config.voice || "F1" /* F1 */;
|
|
661
664
|
this.language = config.language || "en" /* ENGLISH */;
|
|
665
|
+
this.serverUrl = config.serverUrl || DEFAULT_URLS.VOICE_AGENT;
|
|
662
666
|
this.onTranscription = config.onTranscription;
|
|
663
667
|
this.onResponse = config.onResponse;
|
|
664
668
|
this.onAudioCallback = config.onAudio;
|
|
@@ -686,13 +690,28 @@ var VoiceAgentClient = class {
|
|
|
686
690
|
}
|
|
687
691
|
}
|
|
688
692
|
return new Promise((resolve, reject) => {
|
|
693
|
+
let settled = false;
|
|
694
|
+
const settle = (fn) => {
|
|
695
|
+
if (!settled) {
|
|
696
|
+
settled = true;
|
|
697
|
+
fn();
|
|
698
|
+
}
|
|
699
|
+
};
|
|
689
700
|
try {
|
|
690
|
-
let url =
|
|
701
|
+
let url = this.serverUrl;
|
|
691
702
|
if (this.apiKey) {
|
|
692
703
|
const separator = url.includes("?") ? "&" : "?";
|
|
693
704
|
url += `${separator}api_key=${this.apiKey}`;
|
|
694
705
|
}
|
|
695
|
-
|
|
706
|
+
const redactedUrl = url.replace(/api_key=[^&]+/, "api_key=***");
|
|
707
|
+
sdkTrace("ws.connect", {
|
|
708
|
+
endpoint: this.serverUrl,
|
|
709
|
+
url: redactedUrl,
|
|
710
|
+
enableAudio: this.enableAudio,
|
|
711
|
+
wantVisemes: this.wantVisemes,
|
|
712
|
+
hasAudioManager: Boolean(this.audioManager)
|
|
713
|
+
});
|
|
714
|
+
console.log(`\u{1F517} Connecting to ${this.serverUrl}...`);
|
|
696
715
|
this.ws = new WebSocket(url);
|
|
697
716
|
this.ws.binaryType = "arraybuffer";
|
|
698
717
|
this.ws.onopen = async () => {
|
|
@@ -700,6 +719,7 @@ var VoiceAgentClient = class {
|
|
|
700
719
|
this.reconnectAttempts = 0;
|
|
701
720
|
this.reconnecting = false;
|
|
702
721
|
console.log("\u2705 Connected to voice agent!");
|
|
722
|
+
sdkTrace("ws.open");
|
|
703
723
|
this.sendConfig();
|
|
704
724
|
if (this.audioManager) {
|
|
705
725
|
await this.audioManager.startMicrophone((data) => {
|
|
@@ -708,22 +728,54 @@ var VoiceAgentClient = class {
|
|
|
708
728
|
}
|
|
709
729
|
});
|
|
710
730
|
}
|
|
711
|
-
resolve(true);
|
|
731
|
+
settle(() => resolve(true));
|
|
712
732
|
};
|
|
713
733
|
this.ws.onmessage = async (event) => {
|
|
714
734
|
if (event.data instanceof ArrayBuffer) {
|
|
735
|
+
sdkTrace("ws.message.binary", { bytes: event.data.byteLength });
|
|
715
736
|
this.handleBinaryMessage(new Uint8Array(event.data));
|
|
716
737
|
} else {
|
|
738
|
+
sdkTrace("ws.message.text", { length: String(event.data).length });
|
|
717
739
|
this.handleTextMessage(event.data.toString());
|
|
718
740
|
}
|
|
719
741
|
};
|
|
720
742
|
this.ws.onerror = (err) => {
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
743
|
+
const error = new LokutorError("ws.close", "WebSocket connection error", {
|
|
744
|
+
detail: `readyState=${this.ws?.readyState}, bufferedAmount=${this.ws?.bufferedAmount}`,
|
|
745
|
+
original: err,
|
|
746
|
+
retryable: true
|
|
747
|
+
});
|
|
748
|
+
console.error("\u274C WebSocket error:", error.message);
|
|
749
|
+
sdkTrace("ws.error", { code: error.code, message: error.message });
|
|
750
|
+
if (this.onError) this.onError(error);
|
|
751
|
+
if (!this.isConnected) {
|
|
752
|
+
settle(() => reject(error));
|
|
753
|
+
}
|
|
724
754
|
};
|
|
725
|
-
this.ws.onclose = () => {
|
|
755
|
+
this.ws.onclose = (event) => {
|
|
726
756
|
this.isConnected = false;
|
|
757
|
+
const diagnostic = {
|
|
758
|
+
code: event.code,
|
|
759
|
+
reason: event.reason,
|
|
760
|
+
wasClean: event.wasClean,
|
|
761
|
+
url: this.serverUrl,
|
|
762
|
+
isUserDisconnect: this.isUserDisconnect,
|
|
763
|
+
reconnectAttempts: this.reconnectAttempts
|
|
764
|
+
};
|
|
765
|
+
sdkTrace("ws.close", diagnostic);
|
|
766
|
+
if (!settled && !this.isUserDisconnect) {
|
|
767
|
+
const error = new LokutorError("ws.close", `WebSocket closed unexpectedly (code ${event.code})`, {
|
|
768
|
+
detail: event.reason || "No reason provided",
|
|
769
|
+
retryable: event.code !== 1008
|
|
770
|
+
});
|
|
771
|
+
settle(() => reject(error));
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
if (!event.wasClean && event.code === 1006 && this.reconnectAttempts === 0 && !this.isUserDisconnect) {
|
|
775
|
+
console.error("\u274C Connection rejected (code 1006). Likely causes: invalid API key, endpoint unavailable, or CORS blocked.");
|
|
776
|
+
console.error(" URL:", this.serverUrl.replace(/api_key=[^&]+/, "api_key=***"));
|
|
777
|
+
}
|
|
778
|
+
console.log(`\u{1F50C} WebSocket closed \u2014 code: ${event.code}, reason: "${event.reason || "none"}", clean: ${event.wasClean}`);
|
|
727
779
|
if (!this.isUserDisconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
728
780
|
this.reconnecting = true;
|
|
729
781
|
this.reconnectAttempts++;
|
|
@@ -739,37 +791,27 @@ var VoiceAgentClient = class {
|
|
|
739
791
|
}
|
|
740
792
|
};
|
|
741
793
|
} catch (err) {
|
|
742
|
-
|
|
743
|
-
|
|
794
|
+
const error = err instanceof LokutorError ? err : new LokutorError("internal.error", "Failed to create WebSocket connection", { original: err });
|
|
795
|
+
if (this.onError) this.onError(error);
|
|
796
|
+
settle(() => reject(error));
|
|
744
797
|
}
|
|
745
798
|
});
|
|
746
799
|
}
|
|
747
800
|
/**
|
|
748
801
|
* The "Golden Path" - Starts a managed session with hardware handled automatically.
|
|
749
|
-
* This is the recommended way to start a conversation in
|
|
802
|
+
* This is the recommended way to start a conversation in browser environments.
|
|
750
803
|
*/
|
|
751
804
|
async startManaged(config) {
|
|
752
805
|
this.enableAudio = true;
|
|
753
806
|
if (config?.audioManager) {
|
|
754
807
|
this.audioManager = config.audioManager;
|
|
755
808
|
} else if (!this.audioManager) {
|
|
756
|
-
if (typeof window
|
|
757
|
-
|
|
758
|
-
} else {
|
|
759
|
-
try {
|
|
760
|
-
const { NodeAudioManager: NodeAudioManager2 } = await Promise.resolve().then(() => (init_node_audio(), node_audio_exports));
|
|
761
|
-
this.audioManager = new NodeAudioManager2();
|
|
762
|
-
} catch (e) {
|
|
763
|
-
console.error('\u274C Failed to load NodeAudioManager. Please ensure "speaker" and "node-record-lpcm16" are installed.');
|
|
764
|
-
}
|
|
809
|
+
if (typeof window === "undefined") {
|
|
810
|
+
throw new LokutorError("internal.error", "startManaged() requires a browser environment. Pass a custom audioManager for non-browser runtimes.", { retryable: false });
|
|
765
811
|
}
|
|
812
|
+
this.audioManager = new BrowserAudioManager();
|
|
766
813
|
}
|
|
767
814
|
await this.connect();
|
|
768
|
-
if (this.audioManager && this.isConnected) {
|
|
769
|
-
await this.audioManager.startMicrophone((data) => {
|
|
770
|
-
this.sendAudio(data);
|
|
771
|
-
});
|
|
772
|
-
}
|
|
773
815
|
return this;
|
|
774
816
|
}
|
|
775
817
|
/**
|
|
@@ -777,10 +819,18 @@ var VoiceAgentClient = class {
|
|
|
777
819
|
*/
|
|
778
820
|
sendConfig() {
|
|
779
821
|
if (!this.ws || !this.isConnected) return;
|
|
780
|
-
this.ws.send(JSON.stringify({ type: "
|
|
822
|
+
this.ws.send(JSON.stringify({ type: "visemes", data: this.wantVisemes }));
|
|
781
823
|
this.ws.send(JSON.stringify({ type: "voice", data: this.voice }));
|
|
782
824
|
this.ws.send(JSON.stringify({ type: "language", data: this.language }));
|
|
783
|
-
this.ws.send(JSON.stringify({ type: "
|
|
825
|
+
this.ws.send(JSON.stringify({ type: "prompt", data: this.prompt }));
|
|
826
|
+
this.ws.send(JSON.stringify({ type: "rates", playback: 44100, input: 16e3 }));
|
|
827
|
+
sdkTrace("ws.send.config", {
|
|
828
|
+
promptLen: this.prompt?.length || 0,
|
|
829
|
+
voice: this.voice,
|
|
830
|
+
language: this.language,
|
|
831
|
+
visemes: this.wantVisemes,
|
|
832
|
+
tools: this.tools?.length || 0
|
|
833
|
+
});
|
|
784
834
|
if (this.tools && this.tools.length > 0) {
|
|
785
835
|
this.ws.send(JSON.stringify({ type: "tools", data: this.tools }));
|
|
786
836
|
}
|
|
@@ -814,6 +864,15 @@ var VoiceAgentClient = class {
|
|
|
814
864
|
handleTextMessage(text) {
|
|
815
865
|
try {
|
|
816
866
|
const msg = JSON.parse(text);
|
|
867
|
+
if (!msg || typeof msg !== "object") {
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
sdkTrace("ws.recv.type", {
|
|
871
|
+
type: msg.type,
|
|
872
|
+
hasData: Object.prototype.hasOwnProperty.call(msg, "data"),
|
|
873
|
+
dataKind: Array.isArray(msg.data) ? "array" : typeof msg.data,
|
|
874
|
+
generation: msg.generation ?? null
|
|
875
|
+
});
|
|
817
876
|
switch (msg.type) {
|
|
818
877
|
case "audio":
|
|
819
878
|
if (msg.data) {
|
|
@@ -826,7 +885,7 @@ var VoiceAgentClient = class {
|
|
|
826
885
|
this.messages.push({
|
|
827
886
|
role,
|
|
828
887
|
text: msg.data,
|
|
829
|
-
timestamp:
|
|
888
|
+
timestamp: nowMs()
|
|
830
889
|
});
|
|
831
890
|
if (msg.role === "user") {
|
|
832
891
|
if (this.onTranscription) this.onTranscription(msg.data);
|
|
@@ -858,19 +917,44 @@ var VoiceAgentClient = class {
|
|
|
858
917
|
console.log(`${icons[msg.data] || ""} Status: ${msg.data}`);
|
|
859
918
|
break;
|
|
860
919
|
case "visemes":
|
|
861
|
-
|
|
862
|
-
|
|
920
|
+
case "viseme": {
|
|
921
|
+
const msgGen = msg.generation ?? this.currentGeneration;
|
|
922
|
+
if (msgGen < this.currentGeneration) {
|
|
923
|
+
sdkTrace("visemes.discard", { msgGen, currentGen: this.currentGeneration });
|
|
924
|
+
break;
|
|
925
|
+
}
|
|
926
|
+
const normalized = extractVisemePayload(msg);
|
|
927
|
+
const explicitlyEmptyArray = Array.isArray(msg?.data) || Array.isArray(msg?.data?.visemes);
|
|
928
|
+
sdkTrace("visemes.recv", {
|
|
929
|
+
rawType: msg.type,
|
|
930
|
+
normalizedCount: normalized.length,
|
|
931
|
+
first: normalized[0] ?? null
|
|
932
|
+
});
|
|
933
|
+
if (normalized.length > 0 || explicitlyEmptyArray) {
|
|
934
|
+
this.emit("visemes", normalized);
|
|
863
935
|
}
|
|
864
936
|
break;
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
937
|
+
}
|
|
938
|
+
case "error": {
|
|
939
|
+
const backendCode = msg.data?.code ?? "internal.error";
|
|
940
|
+
const backendMessage = msg.data?.message ?? msg.data ?? "Unknown server error";
|
|
941
|
+
const backendDetail = msg.data?.detail;
|
|
942
|
+
const backendRetryable = msg.data?.retryable ?? true;
|
|
943
|
+
const error = new LokutorError(backendCode, backendMessage, {
|
|
944
|
+
detail: backendDetail,
|
|
945
|
+
retryable: backendRetryable
|
|
946
|
+
});
|
|
947
|
+
if (this.onError) this.onError(error);
|
|
948
|
+
console.error(`\u274C Server error: [${error.code}] ${error.message}`);
|
|
868
949
|
break;
|
|
950
|
+
}
|
|
869
951
|
case "tool_call":
|
|
870
952
|
console.log(`\u{1F6E0}\uFE0F Tool Call: ${msg.name}(${msg.arguments})`);
|
|
871
953
|
break;
|
|
872
954
|
}
|
|
873
955
|
} catch (e) {
|
|
956
|
+
sdkTrace("ws.recv.parse_error", { preview: text?.slice(0, 120) });
|
|
957
|
+
console.debug("Failed to parse message:", e);
|
|
874
958
|
}
|
|
875
959
|
}
|
|
876
960
|
/**
|
|
@@ -932,6 +1016,21 @@ var VoiceAgentClient = class {
|
|
|
932
1016
|
this.audioManager.cleanup();
|
|
933
1017
|
}
|
|
934
1018
|
this.isConnected = false;
|
|
1019
|
+
this.reconnecting = false;
|
|
1020
|
+
this.reconnectAttempts = 0;
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* Returns true if the client is currently connected.
|
|
1024
|
+
*/
|
|
1025
|
+
get connected() {
|
|
1026
|
+
return this.isConnected;
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Returns the current generation counter.
|
|
1030
|
+
* Useful for correlating audio/viseme chunks with utterances.
|
|
1031
|
+
*/
|
|
1032
|
+
get generation() {
|
|
1033
|
+
return this.currentGeneration;
|
|
935
1034
|
}
|
|
936
1035
|
/**
|
|
937
1036
|
* Toggles the microphone mute state (if managed by client)
|
|
@@ -954,17 +1053,70 @@ var VoiceAgentClient = class {
|
|
|
954
1053
|
}
|
|
955
1054
|
return 0;
|
|
956
1055
|
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Fetch available voice styles from the server.
|
|
1058
|
+
* No authentication required.
|
|
1059
|
+
*/
|
|
1060
|
+
static async fetchVoices(baseUrl) {
|
|
1061
|
+
const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
|
|
1062
|
+
const data = await fetchJson(`${url}/voices`);
|
|
1063
|
+
return data.voices || [];
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Fetch supported languages from the server.
|
|
1067
|
+
* No authentication required.
|
|
1068
|
+
*/
|
|
1069
|
+
static async fetchLanguages(baseUrl) {
|
|
1070
|
+
const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
|
|
1071
|
+
const data = await fetchJson(`${url}/languages`);
|
|
1072
|
+
return data.languages || [];
|
|
1073
|
+
}
|
|
1074
|
+
/**
|
|
1075
|
+
* Fetch loaded TTS model versions from the server.
|
|
1076
|
+
* No authentication required.
|
|
1077
|
+
*/
|
|
1078
|
+
static async fetchModels(baseUrl) {
|
|
1079
|
+
const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
|
|
1080
|
+
const data = await fetchJson(`${url}/models`);
|
|
1081
|
+
return data.models || [];
|
|
1082
|
+
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Fetch server configuration (limits and defaults).
|
|
1085
|
+
* No authentication required.
|
|
1086
|
+
*/
|
|
1087
|
+
static async fetchConfig(baseUrl) {
|
|
1088
|
+
const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
|
|
1089
|
+
return fetchJson(`${url}/config`);
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* Fetch rich runtime status from the server.
|
|
1093
|
+
* No authentication required.
|
|
1094
|
+
*/
|
|
1095
|
+
static async fetchStatus(baseUrl) {
|
|
1096
|
+
const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
|
|
1097
|
+
return fetchJson(`${url}/status`);
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* Fetch health/liveness status from the server.
|
|
1101
|
+
* No authentication required.
|
|
1102
|
+
*/
|
|
1103
|
+
static async fetchHealth(baseUrl) {
|
|
1104
|
+
const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
|
|
1105
|
+
return fetchJson(`${url}/health`);
|
|
1106
|
+
}
|
|
957
1107
|
/**
|
|
958
1108
|
* Update the system prompt mid-conversation
|
|
959
1109
|
*/
|
|
960
1110
|
updatePrompt(newPrompt) {
|
|
961
1111
|
this.prompt = newPrompt;
|
|
962
|
-
if (this.ws && this.isConnected) {
|
|
1112
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.isConnected) {
|
|
963
1113
|
try {
|
|
964
1114
|
this.ws.send(JSON.stringify({ type: "prompt", data: newPrompt }));
|
|
965
1115
|
console.log(`\u2699\uFE0F Updated prompt: ${newPrompt.substring(0, 50)}...`);
|
|
966
1116
|
} catch (error) {
|
|
967
|
-
|
|
1117
|
+
const err = new LokutorError("internal.error", "Failed to update prompt", { original: error });
|
|
1118
|
+
if (this.onError) this.onError(err);
|
|
1119
|
+
console.error("Error updating prompt:", err.message);
|
|
968
1120
|
}
|
|
969
1121
|
} else {
|
|
970
1122
|
console.warn("Not connected - prompt will be updated on next connection");
|
|
@@ -1027,37 +1179,52 @@ var TTSClient = class {
|
|
|
1027
1179
|
visemes: options.visemes || false
|
|
1028
1180
|
};
|
|
1029
1181
|
ws.send(JSON.stringify(req));
|
|
1030
|
-
startTime =
|
|
1182
|
+
startTime = nowMs();
|
|
1031
1183
|
};
|
|
1032
1184
|
ws.onmessage = async (event) => {
|
|
1033
1185
|
refreshTimeout();
|
|
1034
1186
|
if (event.data instanceof ArrayBuffer) {
|
|
1035
1187
|
if (!firstByteReceived) {
|
|
1036
|
-
const ttfb =
|
|
1188
|
+
const ttfb = nowMs() - startTime;
|
|
1037
1189
|
if (options.onTTFB) options.onTTFB(ttfb);
|
|
1038
1190
|
firstByteReceived = true;
|
|
1039
1191
|
}
|
|
1040
1192
|
if (options.onAudio) options.onAudio(new Uint8Array(event.data));
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
const text = event.data.toString();
|
|
1196
|
+
if (text === "EOS") {
|
|
1197
|
+
if (activityTimeout) clearTimeout(activityTimeout);
|
|
1198
|
+
ws.close();
|
|
1199
|
+
resolve();
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
try {
|
|
1203
|
+
const msg = JSON.parse(text);
|
|
1204
|
+
if (msg.type === "audio" && msg.data) {
|
|
1205
|
+
const audioBuffer = base64ToUint8Array(msg.data);
|
|
1206
|
+
if (!firstByteReceived) {
|
|
1207
|
+
const ttfb = nowMs() - startTime;
|
|
1208
|
+
if (options.onTTFB) options.onTTFB(ttfb);
|
|
1209
|
+
firstByteReceived = true;
|
|
1210
|
+
}
|
|
1211
|
+
if (options.onAudio) options.onAudio(audioBuffer);
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
if (msg.type === "visemes" && Array.isArray(msg.data) && options.onVisemes) {
|
|
1215
|
+
options.onVisemes(normalizeVisemes(msg.data));
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
if (Array.isArray(msg) && options.onVisemes) {
|
|
1219
|
+
options.onVisemes(normalizeVisemes(msg));
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
if (msg.type === "eos") {
|
|
1044
1223
|
if (activityTimeout) clearTimeout(activityTimeout);
|
|
1045
1224
|
ws.close();
|
|
1046
1225
|
resolve();
|
|
1047
|
-
return;
|
|
1048
|
-
}
|
|
1049
|
-
try {
|
|
1050
|
-
const msg = JSON.parse(text);
|
|
1051
|
-
if (Array.isArray(msg) && options.onVisemes) {
|
|
1052
|
-
options.onVisemes(msg);
|
|
1053
|
-
}
|
|
1054
|
-
if (msg.type === "eos") {
|
|
1055
|
-
if (activityTimeout) clearTimeout(activityTimeout);
|
|
1056
|
-
ws.close();
|
|
1057
|
-
resolve();
|
|
1058
|
-
}
|
|
1059
|
-
} catch (e) {
|
|
1060
1226
|
}
|
|
1227
|
+
} catch (e) {
|
|
1061
1228
|
}
|
|
1062
1229
|
};
|
|
1063
1230
|
ws.onerror = (err) => {
|
|
@@ -1086,12 +1253,588 @@ async function simpleTTS(options) {
|
|
|
1086
1253
|
const client = new TTSClient({ apiKey: options.apiKey });
|
|
1087
1254
|
return client.synthesize(options);
|
|
1088
1255
|
}
|
|
1256
|
+
|
|
1257
|
+
// src/conversational-panel.ts
|
|
1258
|
+
var PANEL_CSS = (
|
|
1259
|
+
/*css*/
|
|
1260
|
+
`
|
|
1261
|
+
.cv-panel {
|
|
1262
|
+
position: relative;
|
|
1263
|
+
width: 100%;
|
|
1264
|
+
aspect-ratio: 16 / 9;
|
|
1265
|
+
max-height: 800px;
|
|
1266
|
+
display: flex;
|
|
1267
|
+
flex-direction: column;
|
|
1268
|
+
align-items: center;
|
|
1269
|
+
justify-content: center;
|
|
1270
|
+
padding: 2rem 0;
|
|
1271
|
+
margin: 0;
|
|
1272
|
+
background: var(--cv-bg, #0a0a0a);
|
|
1273
|
+
box-shadow: inset 0 10px 40px rgba(0, 0, 0, 0.1), inset 0 0 100px rgba(0, 0, 0, 0.05);
|
|
1274
|
+
border-radius: 40px;
|
|
1275
|
+
overflow: hidden;
|
|
1276
|
+
container-type: inline-size;
|
|
1277
|
+
}
|
|
1278
|
+
.cv-curtain {
|
|
1279
|
+
position: absolute;
|
|
1280
|
+
inset: 0;
|
|
1281
|
+
display: flex;
|
|
1282
|
+
flex-direction: column;
|
|
1283
|
+
align-items: center;
|
|
1284
|
+
justify-content: center;
|
|
1285
|
+
background: var(--cv-bg, #0a0a0a);
|
|
1286
|
+
z-index: 100;
|
|
1287
|
+
transition: transform 1s cubic-bezier(0.16, 1, 0.3, 1);
|
|
1288
|
+
}
|
|
1289
|
+
.cv-curtain .cv-curtain-bg {
|
|
1290
|
+
position: absolute;
|
|
1291
|
+
inset: 0;
|
|
1292
|
+
background: url('/background_gradient.jpeg') center / cover no-repeat;
|
|
1293
|
+
z-index: -1;
|
|
1294
|
+
}
|
|
1295
|
+
.cv-curtain .cv-curtain-overlay {
|
|
1296
|
+
position: absolute;
|
|
1297
|
+
inset: 0;
|
|
1298
|
+
background: var(--cv-accent);
|
|
1299
|
+
opacity: 0.35;
|
|
1300
|
+
z-index: -1;
|
|
1301
|
+
}
|
|
1302
|
+
.cv-curtain.is-up { transform: translateY(-100%); }
|
|
1303
|
+
.cv-curtain-content {
|
|
1304
|
+
display: flex;
|
|
1305
|
+
flex-direction: column;
|
|
1306
|
+
align-items: center;
|
|
1307
|
+
gap: clamp(0.75rem, 1.5cqi, 1.5rem);
|
|
1308
|
+
color: #fff;
|
|
1309
|
+
text-align: center;
|
|
1310
|
+
z-index: 2;
|
|
1311
|
+
padding: clamp(1rem, 2cqi, 2rem);
|
|
1312
|
+
}
|
|
1313
|
+
.cv-curtain-title {
|
|
1314
|
+
font-size: clamp(1.2rem, 4cqi, 2.5rem);
|
|
1315
|
+
font-weight: 800;
|
|
1316
|
+
letter-spacing: -0.03em;
|
|
1317
|
+
margin: 0;
|
|
1318
|
+
text-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
|
1319
|
+
}
|
|
1320
|
+
.cv-curtain-desc {
|
|
1321
|
+
font-size: clamp(0.75rem, 2cqi, 1.1rem);
|
|
1322
|
+
opacity: 0.8;
|
|
1323
|
+
max-width: 400px;
|
|
1324
|
+
margin: 0;
|
|
1325
|
+
line-height: 1.5;
|
|
1326
|
+
}
|
|
1327
|
+
.cv-curtain-btn {
|
|
1328
|
+
margin-top: 1rem;
|
|
1329
|
+
padding: clamp(0.6rem, 1.5cqi, 1rem) clamp(1.5rem, 3cqi, 2.5rem);
|
|
1330
|
+
border-radius: 100px;
|
|
1331
|
+
background: #fff;
|
|
1332
|
+
color: #000;
|
|
1333
|
+
border: none;
|
|
1334
|
+
font-weight: 700;
|
|
1335
|
+
font-size: clamp(0.8rem, 1.5cqi, 1rem);
|
|
1336
|
+
display: flex;
|
|
1337
|
+
align-items: center;
|
|
1338
|
+
gap: 0.75rem;
|
|
1339
|
+
cursor: pointer;
|
|
1340
|
+
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
1341
|
+
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
|
1342
|
+
}
|
|
1343
|
+
.cv-curtain-btn:hover {
|
|
1344
|
+
transform: scale(1.05);
|
|
1345
|
+
background: #f0f0f0;
|
|
1346
|
+
box-shadow: 0 15px 40px rgba(0,0,0,0.3);
|
|
1347
|
+
}
|
|
1348
|
+
.cv-curtain-btn svg { transition: transform 0.3s ease; }
|
|
1349
|
+
.cv-curtain-btn:hover svg { transform: translateX(4px); }
|
|
1350
|
+
.cv-header {
|
|
1351
|
+
width: 100%;
|
|
1352
|
+
display: flex;
|
|
1353
|
+
flex-direction: column;
|
|
1354
|
+
align-items: center;
|
|
1355
|
+
gap: 0.25rem;
|
|
1356
|
+
z-index: 20;
|
|
1357
|
+
margin-bottom: auto;
|
|
1358
|
+
}
|
|
1359
|
+
.cv-title {
|
|
1360
|
+
font-size: clamp(1rem, 2.5cqi, 1.75rem);
|
|
1361
|
+
font-weight: 700;
|
|
1362
|
+
color: #e0e0e0;
|
|
1363
|
+
display: flex;
|
|
1364
|
+
align-items: center;
|
|
1365
|
+
gap: 1rem;
|
|
1366
|
+
letter-spacing: -0.02em;
|
|
1367
|
+
}
|
|
1368
|
+
.cv-title .cv-timer {
|
|
1369
|
+
font-variant-numeric: tabular-nums;
|
|
1370
|
+
color: var(--cv-accent);
|
|
1371
|
+
font-weight: 400;
|
|
1372
|
+
opacity: 0.8;
|
|
1373
|
+
}
|
|
1374
|
+
.cv-visualizer-wrap {
|
|
1375
|
+
position: absolute;
|
|
1376
|
+
top: 50%;
|
|
1377
|
+
left: 50%;
|
|
1378
|
+
transform: translate(-50%, -50%);
|
|
1379
|
+
width: clamp(140px, 40cqi, 280px);
|
|
1380
|
+
height: clamp(140px, 40cqi, 280px);
|
|
1381
|
+
display: flex;
|
|
1382
|
+
align-items: center;
|
|
1383
|
+
justify-content: center;
|
|
1384
|
+
z-index: 10;
|
|
1385
|
+
transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
|
1386
|
+
}
|
|
1387
|
+
.cv-is-speaking .cv-visualizer-wrap {
|
|
1388
|
+
animation: cv-pulse 2.5s infinite ease-in-out;
|
|
1389
|
+
}
|
|
1390
|
+
.cv-is-thinking .cv-visualizer-wrap {
|
|
1391
|
+
opacity: 0.5;
|
|
1392
|
+
transform: translate(-50%, -50%) scale(0.9);
|
|
1393
|
+
}
|
|
1394
|
+
@keyframes cv-pulse {
|
|
1395
|
+
0%, 100% { transform: translate(-50%, -50%) scale(1); }
|
|
1396
|
+
50% { transform: translate(-50%, -50%) scale(1.05); }
|
|
1397
|
+
}
|
|
1398
|
+
.cv-canvas {
|
|
1399
|
+
width: 100% !important;
|
|
1400
|
+
height: 100% !important;
|
|
1401
|
+
position: relative;
|
|
1402
|
+
z-index: 0;
|
|
1403
|
+
}
|
|
1404
|
+
.cv-canvas {
|
|
1405
|
+
width: 100% !important;
|
|
1406
|
+
height: 100% !important;
|
|
1407
|
+
position: relative;
|
|
1408
|
+
z-index: 0;
|
|
1409
|
+
}
|
|
1410
|
+
.cv-controls {
|
|
1411
|
+
width: 100%;
|
|
1412
|
+
display: flex;
|
|
1413
|
+
flex-direction: column;
|
|
1414
|
+
align-items: center;
|
|
1415
|
+
gap: 1.5rem;
|
|
1416
|
+
padding-top: 1rem;
|
|
1417
|
+
margin-top: auto;
|
|
1418
|
+
z-index: 20;
|
|
1419
|
+
}
|
|
1420
|
+
.cv-pill {
|
|
1421
|
+
display: flex;
|
|
1422
|
+
align-items: center;
|
|
1423
|
+
gap: 0.75rem;
|
|
1424
|
+
background: rgba(255, 255, 255, 0.03);
|
|
1425
|
+
backdrop-filter: blur(30px);
|
|
1426
|
+
-webkit-backdrop-filter: blur(30px);
|
|
1427
|
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
1428
|
+
padding: 0.3rem 0.75rem;
|
|
1429
|
+
border-radius: 100px;
|
|
1430
|
+
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
|
|
1431
|
+
}
|
|
1432
|
+
.cv-btn {
|
|
1433
|
+
display: flex;
|
|
1434
|
+
align-items: center;
|
|
1435
|
+
gap: 0.5rem;
|
|
1436
|
+
background: transparent;
|
|
1437
|
+
border: none;
|
|
1438
|
+
color: rgba(255,255,255,0.55);
|
|
1439
|
+
font-weight: 600;
|
|
1440
|
+
font-size: 0.75rem;
|
|
1441
|
+
cursor: pointer;
|
|
1442
|
+
transition: all 0.2s ease;
|
|
1443
|
+
padding: 0.35rem 0.75rem;
|
|
1444
|
+
border-radius: 50px;
|
|
1445
|
+
}
|
|
1446
|
+
.cv-btn:hover { color: #fff; background: rgba(255,255,255,0.05); }
|
|
1447
|
+
.cv-btn--end { color: #ff4444; }
|
|
1448
|
+
.cv-btn--end .cv-btn-box {
|
|
1449
|
+
background: #ff4444;
|
|
1450
|
+
width: 8px;
|
|
1451
|
+
height: 8px;
|
|
1452
|
+
border-radius: 2px;
|
|
1453
|
+
}
|
|
1454
|
+
.cv-btn.is-muted { color: var(--cv-accent); }
|
|
1455
|
+
.cv-error {
|
|
1456
|
+
position: absolute;
|
|
1457
|
+
bottom: 1.25rem;
|
|
1458
|
+
left: 50%;
|
|
1459
|
+
transform: translateX(-50%);
|
|
1460
|
+
background: #0a0a0a;
|
|
1461
|
+
color: #e0e0e0;
|
|
1462
|
+
padding: 0.75rem 1.5rem;
|
|
1463
|
+
border-radius: 12px;
|
|
1464
|
+
font-size: 0.875rem;
|
|
1465
|
+
font-weight: 500;
|
|
1466
|
+
display: none;
|
|
1467
|
+
align-items: center;
|
|
1468
|
+
gap: 0.75rem;
|
|
1469
|
+
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
|
1470
|
+
z-index: 1000;
|
|
1471
|
+
border: 1px solid rgba(255, 255, 255, 0.06);
|
|
1472
|
+
backdrop-filter: blur(20px);
|
|
1473
|
+
}
|
|
1474
|
+
.cv-error.is-visible { display: flex; }
|
|
1475
|
+
.cv-error-icon { color: var(--cv-accent); flex-shrink: 0; }
|
|
1476
|
+
`
|
|
1477
|
+
);
|
|
1478
|
+
var styleInjected = false;
|
|
1479
|
+
function injectStyles() {
|
|
1480
|
+
if (styleInjected) return;
|
|
1481
|
+
const style = document.createElement("style");
|
|
1482
|
+
style.textContent = PANEL_CSS;
|
|
1483
|
+
document.head.appendChild(style);
|
|
1484
|
+
styleInjected = true;
|
|
1485
|
+
}
|
|
1486
|
+
var ConversationalPanel = class {
|
|
1487
|
+
cfg;
|
|
1488
|
+
container;
|
|
1489
|
+
agent = null;
|
|
1490
|
+
visualizer = null;
|
|
1491
|
+
timerTicker = null;
|
|
1492
|
+
connectingSound = null;
|
|
1493
|
+
connectingFadeTimer = null;
|
|
1494
|
+
isRunning = false;
|
|
1495
|
+
// Cached DOM refs
|
|
1496
|
+
el;
|
|
1497
|
+
curtain;
|
|
1498
|
+
curtainTitle;
|
|
1499
|
+
curtainDesc;
|
|
1500
|
+
startBtn;
|
|
1501
|
+
errorEl;
|
|
1502
|
+
errorText;
|
|
1503
|
+
timerEl;
|
|
1504
|
+
canvas;
|
|
1505
|
+
muteBtn;
|
|
1506
|
+
stopBtn;
|
|
1507
|
+
visualizerWrap;
|
|
1508
|
+
// Callbacks
|
|
1509
|
+
onTranscription;
|
|
1510
|
+
onResponse;
|
|
1511
|
+
onStart;
|
|
1512
|
+
onStop;
|
|
1513
|
+
onError;
|
|
1514
|
+
constructor(cfg) {
|
|
1515
|
+
this.cfg = cfg;
|
|
1516
|
+
this.container = cfg.container;
|
|
1517
|
+
injectStyles();
|
|
1518
|
+
this.buildDOM();
|
|
1519
|
+
}
|
|
1520
|
+
buildDOM() {
|
|
1521
|
+
const accent = this.cfg.accentColor || "#a25a6b";
|
|
1522
|
+
const bg = this.cfg.backgroundColor || "#0a0a0a";
|
|
1523
|
+
this.container.style.setProperty("--cv-accent", accent);
|
|
1524
|
+
this.container.style.setProperty("--cv-bg", bg);
|
|
1525
|
+
this.el = document.createElement("div");
|
|
1526
|
+
this.el.className = "cv-panel";
|
|
1527
|
+
this.el.innerHTML = `
|
|
1528
|
+
<div class="cv-curtain">
|
|
1529
|
+
<div class="cv-curtain-bg"></div>
|
|
1530
|
+
<div class="cv-curtain-overlay"></div>
|
|
1531
|
+
<div class="cv-curtain-content">
|
|
1532
|
+
<h3 class="cv-curtain-title">${this.esc(this.cfg.title)}</h3>
|
|
1533
|
+
<p class="cv-curtain-desc">${this.esc(this.cfg.description)}</p>
|
|
1534
|
+
<button class="cv-curtain-btn">
|
|
1535
|
+
<span>Start Conversation</span>
|
|
1536
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="20" height="20">
|
|
1537
|
+
<path d="M5 12h14M12 5l7 7-7 7"/>
|
|
1538
|
+
</svg>
|
|
1539
|
+
</button>
|
|
1540
|
+
</div>
|
|
1541
|
+
<div class="cv-error">
|
|
1542
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
1543
|
+
width="20" height="20" class="cv-error-icon">
|
|
1544
|
+
<circle cx="12" cy="12" r="10"></circle>
|
|
1545
|
+
<path d="M12 8v4"></path>
|
|
1546
|
+
<path d="M12 16h.01"></path>
|
|
1547
|
+
</svg>
|
|
1548
|
+
<span class="cv-error-text"></span>
|
|
1549
|
+
</div>
|
|
1550
|
+
</div>
|
|
1551
|
+
<div class="cv-header">
|
|
1552
|
+
<h2 class="cv-title">${this.esc(this.cfg.title)} <span class="cv-timer">00:00</span></h2>
|
|
1553
|
+
</div>
|
|
1554
|
+
<div class="cv-visualizer-wrap">
|
|
1555
|
+
<canvas class="cv-canvas"></canvas>
|
|
1556
|
+
</div>
|
|
1557
|
+
<div class="cv-controls">
|
|
1558
|
+
<div class="cv-pill">
|
|
1559
|
+
<button class="cv-btn cv-btn--mute">
|
|
1560
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="18" height="18">
|
|
1561
|
+
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
|
|
1562
|
+
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
|
|
1563
|
+
</svg>
|
|
1564
|
+
<span>Mute</span>
|
|
1565
|
+
</button>
|
|
1566
|
+
<button class="cv-btn cv-btn--end">
|
|
1567
|
+
<div class="cv-btn-box"></div>
|
|
1568
|
+
<span>End call</span>
|
|
1569
|
+
</button>
|
|
1570
|
+
</div>
|
|
1571
|
+
</div>
|
|
1572
|
+
`;
|
|
1573
|
+
this.container.appendChild(this.el);
|
|
1574
|
+
this.curtain = this.el.querySelector(".cv-curtain");
|
|
1575
|
+
if (this.cfg.curtainBgSrc) {
|
|
1576
|
+
this.curtain.style.backgroundImage = `url('${this.cfg.curtainBgSrc}')`;
|
|
1577
|
+
}
|
|
1578
|
+
this.curtainTitle = this.el.querySelector(".cv-curtain-title");
|
|
1579
|
+
this.curtainDesc = this.el.querySelector(".cv-curtain-desc");
|
|
1580
|
+
this.startBtn = this.el.querySelector(".cv-curtain-btn");
|
|
1581
|
+
this.errorEl = this.el.querySelector(".cv-error");
|
|
1582
|
+
this.errorText = this.el.querySelector(".cv-error-text");
|
|
1583
|
+
this.timerEl = this.el.querySelector(".cv-timer");
|
|
1584
|
+
this.canvas = this.el.querySelector(".cv-canvas");
|
|
1585
|
+
this.muteBtn = this.el.querySelector(".cv-btn--mute");
|
|
1586
|
+
this.stopBtn = this.el.querySelector(".cv-btn--end");
|
|
1587
|
+
this.visualizerWrap = this.el.querySelector(".cv-visualizer-wrap");
|
|
1588
|
+
this.startBtn.addEventListener("click", () => this.start());
|
|
1589
|
+
this.stopBtn.addEventListener("click", () => this.stop());
|
|
1590
|
+
this.muteBtn.addEventListener("click", () => this.toggleMute());
|
|
1591
|
+
}
|
|
1592
|
+
esc(s) {
|
|
1593
|
+
const d = document.createElement("div");
|
|
1594
|
+
d.textContent = s;
|
|
1595
|
+
return d.innerHTML;
|
|
1596
|
+
}
|
|
1597
|
+
/** Start the conversation (called when user clicks "Start Conversation" or externally) */
|
|
1598
|
+
async start() {
|
|
1599
|
+
if (this.isRunning) return;
|
|
1600
|
+
this.isRunning = true;
|
|
1601
|
+
this.startBtn.disabled = true;
|
|
1602
|
+
this.errorEl.classList.remove("is-visible");
|
|
1603
|
+
this.playConnectingSound();
|
|
1604
|
+
try {
|
|
1605
|
+
const ConvoAgent = this.cfg.ConvoAgent;
|
|
1606
|
+
const SphereVisualizer = this.cfg.SphereVisualizer;
|
|
1607
|
+
const visualizer = new SphereVisualizer(this.canvas);
|
|
1608
|
+
visualizer.resize(280, 280);
|
|
1609
|
+
this.visualizer = visualizer;
|
|
1610
|
+
if (this.cfg.accentColor && typeof visualizer.setAccentColor === "function") {
|
|
1611
|
+
const hex = this.cfg.accentColor;
|
|
1612
|
+
visualizer.setAccentColor(
|
|
1613
|
+
parseInt(hex.slice(1, 3), 16) / 255,
|
|
1614
|
+
parseInt(hex.slice(3, 5), 16) / 255,
|
|
1615
|
+
parseInt(hex.slice(5, 7), 16) / 255
|
|
1616
|
+
);
|
|
1617
|
+
}
|
|
1618
|
+
const agentHandle = new ConvoAgent({
|
|
1619
|
+
apiKey: this.cfg.apiKey,
|
|
1620
|
+
prompt: this.cfg.prompt,
|
|
1621
|
+
voice: this.cfg.voice || "M1",
|
|
1622
|
+
language: this.cfg.language || "en",
|
|
1623
|
+
onStatusChange: (status) => {
|
|
1624
|
+
this.el.classList.remove("cv-is-speaking", "cv-is-thinking");
|
|
1625
|
+
if (status === "speaking") this.el.classList.add("cv-is-speaking");
|
|
1626
|
+
else if (status === "thinking") this.el.classList.add("cv-is-thinking");
|
|
1627
|
+
},
|
|
1628
|
+
onTranscription: (text) => {
|
|
1629
|
+
this.onTranscription?.(text);
|
|
1630
|
+
},
|
|
1631
|
+
onResponse: (text) => {
|
|
1632
|
+
this.onResponse?.(text);
|
|
1633
|
+
},
|
|
1634
|
+
onError: (err) => {
|
|
1635
|
+
if (!this.isRunning) return;
|
|
1636
|
+
this.onError?.(err);
|
|
1637
|
+
this.stop();
|
|
1638
|
+
this.showError("Connection issue. Try again in a moment.");
|
|
1639
|
+
}
|
|
1640
|
+
});
|
|
1641
|
+
this.agent = agentHandle;
|
|
1642
|
+
visualizer.setAudioClient(agentHandle);
|
|
1643
|
+
this.curtain.classList.add("is-up");
|
|
1644
|
+
try {
|
|
1645
|
+
agentHandle.unlockAudioForMobile?.();
|
|
1646
|
+
} catch (_) {
|
|
1647
|
+
}
|
|
1648
|
+
const ok = await agentHandle.connect();
|
|
1649
|
+
if (ok) {
|
|
1650
|
+
this.fadeOutConnectingSound();
|
|
1651
|
+
const wrap = this.visualizerWrap;
|
|
1652
|
+
if (wrap) {
|
|
1653
|
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
1654
|
+
const w = Math.floor(wrap.clientWidth * dpr);
|
|
1655
|
+
const h = Math.floor(wrap.clientHeight * dpr);
|
|
1656
|
+
visualizer.resize(w, h);
|
|
1657
|
+
}
|
|
1658
|
+
visualizer.start();
|
|
1659
|
+
this.startTimer();
|
|
1660
|
+
this.onStart?.();
|
|
1661
|
+
if (window.lucide) window.lucide.createIcons();
|
|
1662
|
+
} else {
|
|
1663
|
+
this.fadeOutConnectingSound();
|
|
1664
|
+
this.playErrorTone();
|
|
1665
|
+
this.curtain.classList.remove("is-up");
|
|
1666
|
+
this.showError("Could not connect. Please try again.");
|
|
1667
|
+
this.isRunning = false;
|
|
1668
|
+
}
|
|
1669
|
+
} catch (err) {
|
|
1670
|
+
this.fadeOutConnectingSound();
|
|
1671
|
+
this.playErrorTone();
|
|
1672
|
+
console.error("ConversationalPanel start error:", err);
|
|
1673
|
+
this.stop();
|
|
1674
|
+
this.showError("Something went wrong. Please try again.");
|
|
1675
|
+
} finally {
|
|
1676
|
+
this.startBtn.disabled = false;
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
/** Stop / disconnect the conversation */
|
|
1680
|
+
stop() {
|
|
1681
|
+
if (!this.isRunning && !this.agent && !this.visualizer) return;
|
|
1682
|
+
this.isRunning = false;
|
|
1683
|
+
this.fadeOutConnectingSound();
|
|
1684
|
+
this.errorEl.classList.remove("is-visible");
|
|
1685
|
+
if (this.agent) {
|
|
1686
|
+
try {
|
|
1687
|
+
this.agent.disconnect();
|
|
1688
|
+
} catch (_) {
|
|
1689
|
+
}
|
|
1690
|
+
this.agent = null;
|
|
1691
|
+
}
|
|
1692
|
+
if (this.timerTicker) {
|
|
1693
|
+
clearInterval(this.timerTicker);
|
|
1694
|
+
this.timerTicker = null;
|
|
1695
|
+
}
|
|
1696
|
+
if (this.visualizer) {
|
|
1697
|
+
this.visualizer.stop();
|
|
1698
|
+
this.visualizer = null;
|
|
1699
|
+
}
|
|
1700
|
+
this.curtain.classList.remove("is-up");
|
|
1701
|
+
this.el.classList.remove("cv-is-speaking", "cv-is-thinking");
|
|
1702
|
+
this.muteBtn.classList.remove("is-muted");
|
|
1703
|
+
const span = this.muteBtn.querySelector("span");
|
|
1704
|
+
if (span) span.textContent = "Mute";
|
|
1705
|
+
this.onStop?.();
|
|
1706
|
+
this.startBtn.disabled = false;
|
|
1707
|
+
}
|
|
1708
|
+
/** Update accent color dynamically */
|
|
1709
|
+
setColor(color) {
|
|
1710
|
+
this.cfg.accentColor = color;
|
|
1711
|
+
this.container.style.setProperty("--cv-accent", color);
|
|
1712
|
+
if (this.visualizer && typeof this.visualizer.setAccentColor === "function") {
|
|
1713
|
+
const r = parseInt(color.slice(1, 3), 16) / 255;
|
|
1714
|
+
const g = parseInt(color.slice(3, 5), 16) / 255;
|
|
1715
|
+
const b = parseInt(color.slice(5, 7), 16) / 255;
|
|
1716
|
+
this.visualizer.setAccentColor(r, g, b);
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
/** Update title text */
|
|
1720
|
+
setTitle(title) {
|
|
1721
|
+
this.cfg.title = title;
|
|
1722
|
+
this.curtainTitle.textContent = title;
|
|
1723
|
+
const h2 = this.el.querySelector(".cv-title");
|
|
1724
|
+
if (h2) h2.innerHTML = `${this.esc(title)} <span class="cv-timer">${this.timerEl?.textContent || "00:00"}</span>`;
|
|
1725
|
+
}
|
|
1726
|
+
/** Update description text */
|
|
1727
|
+
setDescription(desc) {
|
|
1728
|
+
this.cfg.description = desc;
|
|
1729
|
+
this.curtainDesc.textContent = desc;
|
|
1730
|
+
}
|
|
1731
|
+
/** Update prompt (only takes effect on next start()) */
|
|
1732
|
+
setPrompt(prompt) {
|
|
1733
|
+
this.cfg.prompt = prompt;
|
|
1734
|
+
}
|
|
1735
|
+
/** Show an error message */
|
|
1736
|
+
showError(text) {
|
|
1737
|
+
this.errorText.textContent = text;
|
|
1738
|
+
this.errorEl.classList.add("is-visible");
|
|
1739
|
+
setTimeout(() => {
|
|
1740
|
+
this.errorEl.classList.remove("is-visible");
|
|
1741
|
+
}, 5e3);
|
|
1742
|
+
}
|
|
1743
|
+
/** Destroy the component, removing all DOM and stopping any active session */
|
|
1744
|
+
destroy() {
|
|
1745
|
+
this.stop();
|
|
1746
|
+
this.el.remove();
|
|
1747
|
+
}
|
|
1748
|
+
// ─── internal helpers ────────────────────────────────────
|
|
1749
|
+
startTimer() {
|
|
1750
|
+
let elapsed = 0;
|
|
1751
|
+
this.timerEl.textContent = "00:00";
|
|
1752
|
+
this.timerTicker = window.setInterval(() => {
|
|
1753
|
+
elapsed++;
|
|
1754
|
+
const m = Math.floor(elapsed / 60).toString().padStart(2, "0");
|
|
1755
|
+
const s = (elapsed % 60).toString().padStart(2, "0");
|
|
1756
|
+
this.timerEl.textContent = `${m}:${s}`;
|
|
1757
|
+
}, 1e3);
|
|
1758
|
+
}
|
|
1759
|
+
toggleMute() {
|
|
1760
|
+
if (!this.agent) return;
|
|
1761
|
+
try {
|
|
1762
|
+
const muted = this.agent.toggleMute();
|
|
1763
|
+
this.muteBtn.classList.toggle("is-muted", muted);
|
|
1764
|
+
const span = this.muteBtn.querySelector("span");
|
|
1765
|
+
if (span) span.textContent = muted ? "Unmute" : "Mute";
|
|
1766
|
+
if (muted) {
|
|
1767
|
+
this.agent.pauseAudio?.();
|
|
1768
|
+
this.visualizer?.setDeactivated?.(true);
|
|
1769
|
+
} else {
|
|
1770
|
+
this.agent.resumeAudio?.();
|
|
1771
|
+
this.visualizer?.setDeactivated?.(false);
|
|
1772
|
+
}
|
|
1773
|
+
} catch (_) {
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
playConnectingSound() {
|
|
1777
|
+
this.fadeOutConnectingSound();
|
|
1778
|
+
const src = this.cfg.connectingSoundSrc || "/connecting.mp3";
|
|
1779
|
+
this.connectingSound = new Audio(src);
|
|
1780
|
+
this.connectingSound.volume = 0.6;
|
|
1781
|
+
this.connectingSound.play().catch(() => {
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
fadeOutConnectingSound() {
|
|
1785
|
+
if (this.connectingFadeTimer) {
|
|
1786
|
+
clearInterval(this.connectingFadeTimer);
|
|
1787
|
+
this.connectingFadeTimer = null;
|
|
1788
|
+
}
|
|
1789
|
+
if (!this.connectingSound) return;
|
|
1790
|
+
const startVol = this.connectingSound.volume;
|
|
1791
|
+
const steps = 15;
|
|
1792
|
+
let step = 0;
|
|
1793
|
+
this.connectingFadeTimer = window.setInterval(() => {
|
|
1794
|
+
step++;
|
|
1795
|
+
const progress = step / steps;
|
|
1796
|
+
if (this.connectingSound) this.connectingSound.volume = startVol * Math.max(0, 1 - progress);
|
|
1797
|
+
if (step >= steps) {
|
|
1798
|
+
if (this.connectingFadeTimer) {
|
|
1799
|
+
clearInterval(this.connectingFadeTimer);
|
|
1800
|
+
this.connectingFadeTimer = null;
|
|
1801
|
+
}
|
|
1802
|
+
if (this.connectingSound) {
|
|
1803
|
+
this.connectingSound.pause();
|
|
1804
|
+
this.connectingSound.currentTime = 0;
|
|
1805
|
+
this.connectingSound = null;
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
}, 100);
|
|
1809
|
+
}
|
|
1810
|
+
playErrorTone() {
|
|
1811
|
+
try {
|
|
1812
|
+
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
1813
|
+
const now = ctx.currentTime;
|
|
1814
|
+
[0, 0.3].forEach((offset, i) => {
|
|
1815
|
+
const osc = ctx.createOscillator();
|
|
1816
|
+
const gain = ctx.createGain();
|
|
1817
|
+
osc.connect(gain);
|
|
1818
|
+
gain.connect(ctx.destination);
|
|
1819
|
+
osc.type = "sine";
|
|
1820
|
+
osc.frequency.setValueAtTime(600 - i * 200, now + offset);
|
|
1821
|
+
gain.gain.setValueAtTime(0.25, now + offset);
|
|
1822
|
+
gain.gain.exponentialRampToValueAtTime(1e-3, now + offset + 0.35);
|
|
1823
|
+
osc.start(now + offset);
|
|
1824
|
+
osc.stop(now + offset + 0.4);
|
|
1825
|
+
});
|
|
1826
|
+
} catch (_) {
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
};
|
|
1089
1830
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1090
1831
|
0 && (module.exports = {
|
|
1091
1832
|
AUDIO_CONFIG,
|
|
1092
1833
|
BrowserAudioManager,
|
|
1834
|
+
ConversationalPanel,
|
|
1093
1835
|
DEFAULT_URLS,
|
|
1094
1836
|
Language,
|
|
1837
|
+
LokutorError,
|
|
1095
1838
|
StreamResampler,
|
|
1096
1839
|
TTSClient,
|
|
1097
1840
|
VoiceAgentClient,
|
|
@@ -1100,6 +1843,7 @@ async function simpleTTS(options) {
|
|
|
1100
1843
|
bytesToPcm16,
|
|
1101
1844
|
calculateRMS,
|
|
1102
1845
|
float32ToPcm16,
|
|
1846
|
+
isRetryable,
|
|
1103
1847
|
normalizeAudio,
|
|
1104
1848
|
pcm16ToBytes,
|
|
1105
1849
|
pcm16ToFloat32,
|