@lokutor/sdk 1.4.1 → 1.5.1
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 +76 -5
- package/dist/index.d.ts +76 -5
- package/dist/index.js +273 -26
- package/dist/index.mjs +271 -26
- package/package.json +1 -1
- package/src/browser-audio.ts +149 -13
- package/src/client.ts +76 -8
- package/src/conversational-panel.ts +137 -18
- package/src/index.ts +1 -1
- package/src/types.ts +4 -0
package/dist/index.mjs
CHANGED
|
@@ -79,6 +79,8 @@ function isRetryableCode(code) {
|
|
|
79
79
|
"auth.missing_key",
|
|
80
80
|
"auth.invalid_key",
|
|
81
81
|
"auth.time_limited",
|
|
82
|
+
"session.ended",
|
|
83
|
+
"audio.microphone_unavailable",
|
|
82
84
|
"validation.invalid_voice",
|
|
83
85
|
"validation.invalid_language",
|
|
84
86
|
"validation.text_too_long",
|
|
@@ -233,6 +235,36 @@ var StreamResampler = class {
|
|
|
233
235
|
};
|
|
234
236
|
|
|
235
237
|
// src/browser-audio.ts
|
|
238
|
+
function requestMicrophone(constraints = {}) {
|
|
239
|
+
if (typeof navigator === "undefined") {
|
|
240
|
+
return Promise.reject(new Error("Microphone capture needs a browser"));
|
|
241
|
+
}
|
|
242
|
+
const session = navigator.audioSession;
|
|
243
|
+
if (session && session.type !== "auto" && session.type !== "play-and-record") {
|
|
244
|
+
try {
|
|
245
|
+
session.type = "auto";
|
|
246
|
+
} catch (_) {
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (!navigator.mediaDevices?.getUserMedia) {
|
|
250
|
+
return Promise.reject(new Error("Microphone capture is not available here (it needs HTTPS)"));
|
|
251
|
+
}
|
|
252
|
+
return navigator.mediaDevices.getUserMedia({
|
|
253
|
+
audio: {
|
|
254
|
+
autoGainControl: constraints.autoGainControl ?? true,
|
|
255
|
+
echoCancellation: constraints.echoCancellation ?? true,
|
|
256
|
+
noiseSuppression: constraints.noiseSuppression ?? true
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
async function resumeWithin(ctx, ms) {
|
|
261
|
+
if (ctx.state === "running") return;
|
|
262
|
+
await Promise.race([
|
|
263
|
+
ctx.resume().catch(() => {
|
|
264
|
+
}),
|
|
265
|
+
new Promise((r) => setTimeout(r, ms))
|
|
266
|
+
]);
|
|
267
|
+
}
|
|
236
268
|
var BrowserAudioManager = class {
|
|
237
269
|
audioContext = null;
|
|
238
270
|
mediaStreamAudioSourceNode = null;
|
|
@@ -254,6 +286,12 @@ var BrowserAudioManager = class {
|
|
|
254
286
|
autoGainControl;
|
|
255
287
|
echoCancellation;
|
|
256
288
|
noiseSuppression;
|
|
289
|
+
keepAwake;
|
|
290
|
+
providedMicrophone;
|
|
291
|
+
// Screen wake lock, held while the microphone is live. Browsers release it whenever the page is
|
|
292
|
+
// hidden, so it is taken again when the page becomes visible.
|
|
293
|
+
wakeLock = null;
|
|
294
|
+
visibilityHandler = null;
|
|
257
295
|
// Callbacks
|
|
258
296
|
onAudioInput;
|
|
259
297
|
onInputError;
|
|
@@ -266,6 +304,10 @@ var BrowserAudioManager = class {
|
|
|
266
304
|
this.autoGainControl = config.autoGainControl ?? true;
|
|
267
305
|
this.echoCancellation = config.echoCancellation ?? true;
|
|
268
306
|
this.noiseSuppression = config.noiseSuppression ?? true;
|
|
307
|
+
this.keepAwake = config.keepAwake ?? true;
|
|
308
|
+
this.providedMicrophone = config.microphone ?? null;
|
|
309
|
+
if (this.providedMicrophone instanceof Promise) this.providedMicrophone.catch(() => {
|
|
310
|
+
});
|
|
269
311
|
this.onInputError = config.onInputError;
|
|
270
312
|
}
|
|
271
313
|
/**
|
|
@@ -281,9 +323,8 @@ var BrowserAudioManager = class {
|
|
|
281
323
|
if (!this.audioContext) {
|
|
282
324
|
throw new Error("Failed to initialize AudioContext");
|
|
283
325
|
}
|
|
284
|
-
if (this.audioContext.state
|
|
285
|
-
await this.audioContext
|
|
286
|
-
console.log("\u{1F442} AudioContext resumed");
|
|
326
|
+
if (this.audioContext.state !== "running") {
|
|
327
|
+
await resumeWithin(this.audioContext, 500);
|
|
287
328
|
}
|
|
288
329
|
if (analyserConfig?.enabled !== false) {
|
|
289
330
|
this.analyserNode = this.audioContext.createAnalyser();
|
|
@@ -297,10 +338,17 @@ var BrowserAudioManager = class {
|
|
|
297
338
|
if (!this.audioContext) {
|
|
298
339
|
await this.init();
|
|
299
340
|
}
|
|
341
|
+
if (this.isListening && this.mediaStream) {
|
|
342
|
+
this.onAudioInput = onAudioInput;
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
300
345
|
try {
|
|
301
346
|
this.onAudioInput = onAudioInput;
|
|
302
347
|
this.isListening = true;
|
|
303
|
-
|
|
348
|
+
const provided = this.providedMicrophone;
|
|
349
|
+
this.providedMicrophone = null;
|
|
350
|
+
const stream = provided ? await provided : null;
|
|
351
|
+
this.mediaStream = stream && stream.getAudioTracks().some((t) => t.readyState === "live") ? stream : await navigator.mediaDevices.getUserMedia({
|
|
304
352
|
audio: {
|
|
305
353
|
autoGainControl: this.autoGainControl,
|
|
306
354
|
echoCancellation: this.echoCancellation,
|
|
@@ -331,7 +379,9 @@ var BrowserAudioManager = class {
|
|
|
331
379
|
this._processAudioInput(event);
|
|
332
380
|
};
|
|
333
381
|
console.log("\u{1F3A4} Microphone started");
|
|
382
|
+
this.holdScreenAwake();
|
|
334
383
|
} catch (error) {
|
|
384
|
+
this.isListening = false;
|
|
335
385
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
336
386
|
if (this.onInputError) this.onInputError(err);
|
|
337
387
|
throw err;
|
|
@@ -369,6 +419,7 @@ var BrowserAudioManager = class {
|
|
|
369
419
|
*/
|
|
370
420
|
stopMicrophone() {
|
|
371
421
|
this.isListening = false;
|
|
422
|
+
this.releaseScreenAwake();
|
|
372
423
|
if (this.mediaStream) {
|
|
373
424
|
this.mediaStream.getTracks().forEach((track) => track.stop());
|
|
374
425
|
this.mediaStream = null;
|
|
@@ -383,6 +434,53 @@ var BrowserAudioManager = class {
|
|
|
383
434
|
}
|
|
384
435
|
console.log("\u{1F3A4} Microphone stopped");
|
|
385
436
|
}
|
|
437
|
+
/**
|
|
438
|
+
* Keep the screen on while the microphone is live, and bring audio back when the page returns
|
|
439
|
+
* to the foreground (iOS leaves the context "interrupted" after the screen locks).
|
|
440
|
+
*/
|
|
441
|
+
holdScreenAwake() {
|
|
442
|
+
if (typeof document === "undefined") return;
|
|
443
|
+
if (!this.visibilityHandler) {
|
|
444
|
+
this.visibilityHandler = () => {
|
|
445
|
+
if (document.visibilityState !== "visible" || !this.isListening) return;
|
|
446
|
+
if (this.audioContext && this.audioContext.state !== "running") {
|
|
447
|
+
this.audioContext.resume().catch(() => {
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
void this.requestWakeLock();
|
|
451
|
+
};
|
|
452
|
+
document.addEventListener("visibilitychange", this.visibilityHandler);
|
|
453
|
+
}
|
|
454
|
+
void this.requestWakeLock();
|
|
455
|
+
}
|
|
456
|
+
async requestWakeLock() {
|
|
457
|
+
if (!this.keepAwake || this.wakeLock || typeof navigator === "undefined") return;
|
|
458
|
+
const api = navigator.wakeLock;
|
|
459
|
+
if (!api?.request || document.visibilityState !== "visible") return;
|
|
460
|
+
try {
|
|
461
|
+
const lock = await api.request("screen");
|
|
462
|
+
if (!this.isListening) {
|
|
463
|
+
await lock.release().catch(() => {
|
|
464
|
+
});
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
this.wakeLock = lock;
|
|
468
|
+
lock.addEventListener?.("release", () => {
|
|
469
|
+
if (this.wakeLock === lock) this.wakeLock = null;
|
|
470
|
+
});
|
|
471
|
+
} catch (_) {
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
releaseScreenAwake() {
|
|
475
|
+
if (this.visibilityHandler && typeof document !== "undefined") {
|
|
476
|
+
document.removeEventListener("visibilitychange", this.visibilityHandler);
|
|
477
|
+
this.visibilityHandler = null;
|
|
478
|
+
}
|
|
479
|
+
const lock = this.wakeLock;
|
|
480
|
+
this.wakeLock = null;
|
|
481
|
+
lock?.release().catch(() => {
|
|
482
|
+
});
|
|
483
|
+
}
|
|
386
484
|
/**
|
|
387
485
|
* Play back audio received from the server
|
|
388
486
|
* @param pcm16Data Int16 PCM audio data at SPEAKER_SAMPLE_RATE
|
|
@@ -518,6 +616,13 @@ var BrowserAudioManager = class {
|
|
|
518
616
|
this.analyserNode.disconnect();
|
|
519
617
|
this.analyserNode = null;
|
|
520
618
|
}
|
|
619
|
+
if (this.audioContext) {
|
|
620
|
+
const ctx = this.audioContext;
|
|
621
|
+
this.audioContext = null;
|
|
622
|
+
this.nextPlaybackTime = 0;
|
|
623
|
+
ctx.close().catch(() => {
|
|
624
|
+
});
|
|
625
|
+
}
|
|
521
626
|
}
|
|
522
627
|
/**
|
|
523
628
|
* Get current audio context state
|
|
@@ -582,6 +687,7 @@ async function fetchJson(url, timeoutMs = 1e4) {
|
|
|
582
687
|
});
|
|
583
688
|
}
|
|
584
689
|
}
|
|
690
|
+
var RECONNECTABLE_CLOSE_CODES = /* @__PURE__ */ new Set([1001, 1006, 1011, 1012, 1013, 1014]);
|
|
585
691
|
function base64ToUint8Array(base64) {
|
|
586
692
|
const binaryString = atob(base64);
|
|
587
693
|
const bytes = new Uint8Array(binaryString.length);
|
|
@@ -661,6 +767,9 @@ var VoiceAgentClient = class {
|
|
|
661
767
|
reconnectAttempts = 0;
|
|
662
768
|
maxReconnectAttempts = 5;
|
|
663
769
|
reconnectTimer = null;
|
|
770
|
+
// The microphone is started once per session of this client, not once per socket: a reconnect
|
|
771
|
+
// reuses it. Starting it again on every open ran two capture pipelines into one resampler.
|
|
772
|
+
micStarted = false;
|
|
664
773
|
// Runtime state
|
|
665
774
|
serverPlaybackRate = 44100;
|
|
666
775
|
serverInputRate = 16e3;
|
|
@@ -731,15 +840,35 @@ var VoiceAgentClient = class {
|
|
|
731
840
|
this.isConnected = true;
|
|
732
841
|
this.reconnectAttempts = 0;
|
|
733
842
|
this.reconnecting = false;
|
|
843
|
+
this.currentGeneration = 0;
|
|
734
844
|
console.log("\u2705 Connected to voice agent!");
|
|
735
845
|
sdkTrace("ws.open");
|
|
736
846
|
this.sendConfig();
|
|
737
|
-
if (this.audioManager) {
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
this.
|
|
847
|
+
if (this.audioManager && !this.micStarted) {
|
|
848
|
+
try {
|
|
849
|
+
await this.audioManager.startMicrophone((data) => {
|
|
850
|
+
if (this.isConnected) {
|
|
851
|
+
this.sendAudio(data);
|
|
852
|
+
}
|
|
853
|
+
});
|
|
854
|
+
} catch (e) {
|
|
855
|
+
const name = e?.name || "Error";
|
|
856
|
+
const error = new LokutorError(
|
|
857
|
+
"audio.microphone_unavailable",
|
|
858
|
+
name === "NotAllowedError" || name === "SecurityError" ? "Microphone access was not allowed." : "The microphone could not be started.",
|
|
859
|
+
{ detail: `${name}: ${e?.message ?? e}`, original: e, retryable: false }
|
|
860
|
+
);
|
|
861
|
+
sdkTrace("mic.failed", { name });
|
|
862
|
+
settle(() => reject(error));
|
|
863
|
+
this.isUserDisconnect = true;
|
|
864
|
+
try {
|
|
865
|
+
this.ws?.close();
|
|
866
|
+
} catch (_) {
|
|
741
867
|
}
|
|
742
|
-
|
|
868
|
+
this.emit("error", error);
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
this.micStarted = true;
|
|
743
872
|
}
|
|
744
873
|
settle(() => resolve(true));
|
|
745
874
|
};
|
|
@@ -790,6 +919,17 @@ var VoiceAgentClient = class {
|
|
|
790
919
|
console.error(" URL:", this.serverUrl.replace(/api_key=[^&]+/, "api_key=***"));
|
|
791
920
|
}
|
|
792
921
|
console.log(`\u{1F50C} WebSocket closed \u2014 code: ${event.code}, reason: "${event.reason || "none"}", clean: ${event.wasClean}`);
|
|
922
|
+
if (!this.isUserDisconnect && !RECONNECTABLE_CLOSE_CODES.has(event.code)) {
|
|
923
|
+
const ended = new LokutorError("session.ended", event.reason || "The session was ended by the server.", {
|
|
924
|
+
detail: `close code ${event.code}`,
|
|
925
|
+
retryable: false
|
|
926
|
+
});
|
|
927
|
+
console.log(`Session ended by the server (code ${event.code})`);
|
|
928
|
+
this.emit("ended", { code: event.code, reason: event.reason || "" });
|
|
929
|
+
if (this.onError) this.onError(ended);
|
|
930
|
+
if (this.onStatus) this.onStatus("disconnected");
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
793
933
|
if (!this.isUserDisconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
794
934
|
this.reconnecting = true;
|
|
795
935
|
this.reconnectAttempts++;
|
|
@@ -979,11 +1119,18 @@ var VoiceAgentClient = class {
|
|
|
979
1119
|
detail: backendDetail,
|
|
980
1120
|
retryable: backendRetryable
|
|
981
1121
|
});
|
|
982
|
-
if (this.onError) this.onError(error);
|
|
983
1122
|
this.emit("error", error);
|
|
984
1123
|
console.error(`\u274C Server error: [${error.code}] ${error.message}`);
|
|
985
1124
|
break;
|
|
986
1125
|
}
|
|
1126
|
+
case "session_limit": {
|
|
1127
|
+
const seconds = Number(msg.data?.seconds ?? msg.seconds);
|
|
1128
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
1129
|
+
const limit = { seconds, reason: msg.data?.reason ?? msg.reason };
|
|
1130
|
+
this.emit("session_limit", limit);
|
|
1131
|
+
}
|
|
1132
|
+
break;
|
|
1133
|
+
}
|
|
987
1134
|
case "tool_call":
|
|
988
1135
|
this.emit("tool_call", { name: msg.name, arguments: msg.arguments });
|
|
989
1136
|
console.log(`\u{1F6E0}\uFE0F Tool Call: ${msg.name}(${msg.arguments})`);
|
|
@@ -1057,6 +1204,7 @@ var VoiceAgentClient = class {
|
|
|
1057
1204
|
if (this.audioManager) {
|
|
1058
1205
|
this.audioManager.cleanup();
|
|
1059
1206
|
}
|
|
1207
|
+
this.micStarted = false;
|
|
1060
1208
|
this.isConnected = false;
|
|
1061
1209
|
this.configConfirmed = false;
|
|
1062
1210
|
this.reconnecting = false;
|
|
@@ -1706,6 +1854,22 @@ var PANEL_CSS = (
|
|
|
1706
1854
|
margin-bottom: auto;
|
|
1707
1855
|
padding-top: 1.5rem;
|
|
1708
1856
|
}
|
|
1857
|
+
.cv-countdown, .cv-note {
|
|
1858
|
+
display: none;
|
|
1859
|
+
margin-top: 0.4rem;
|
|
1860
|
+
padding: 0.15rem 0.6rem;
|
|
1861
|
+
border-radius: 999px;
|
|
1862
|
+
border: 1px solid var(--cv-ui-border, rgba(255,255,255,0.1));
|
|
1863
|
+
background: var(--cv-ui-bg, rgba(255,255,255,0.05));
|
|
1864
|
+
color: var(--cv-text-dim, rgba(255,255,255,0.6));
|
|
1865
|
+
font-size: 0.75rem;
|
|
1866
|
+
font-variant-numeric: tabular-nums;
|
|
1867
|
+
width: fit-content;
|
|
1868
|
+
margin-left: auto;
|
|
1869
|
+
margin-right: auto;
|
|
1870
|
+
}
|
|
1871
|
+
.cv-countdown.is-visible, .cv-note.is-visible { display: block; }
|
|
1872
|
+
.cv-countdown.is-low { color: var(--cv-accent); border-color: var(--cv-accent); }
|
|
1709
1873
|
.cv-title {
|
|
1710
1874
|
font-size: clamp(1rem, 2.5vw, 1.75rem);
|
|
1711
1875
|
font-weight: 700;
|
|
@@ -1916,6 +2080,17 @@ function injectStyles() {
|
|
|
1916
2080
|
document.head.appendChild(style);
|
|
1917
2081
|
styleInjected = true;
|
|
1918
2082
|
}
|
|
2083
|
+
function microphoneMessage(err) {
|
|
2084
|
+
const name = String(err?.original?.name ?? err?.name ?? "");
|
|
2085
|
+
const detail = String(err?.detail ?? err?.message ?? "");
|
|
2086
|
+
if (/NotAllowedError|SecurityError/.test(name + " " + detail)) {
|
|
2087
|
+
return "Microphone access is blocked. Allow it for this site in your browser settings, then try again.";
|
|
2088
|
+
}
|
|
2089
|
+
if (/NotFoundError|OverconstrainedError/.test(name + " " + detail)) {
|
|
2090
|
+
return "No microphone was found on this device.";
|
|
2091
|
+
}
|
|
2092
|
+
return "The microphone could not start. Please try again.";
|
|
2093
|
+
}
|
|
1919
2094
|
var ConversationalPanel = class {
|
|
1920
2095
|
cfg;
|
|
1921
2096
|
container;
|
|
@@ -1942,6 +2117,11 @@ var ConversationalPanel = class {
|
|
|
1942
2117
|
muteSvg;
|
|
1943
2118
|
stopBtn;
|
|
1944
2119
|
visualizerWrap;
|
|
2120
|
+
countdownEl;
|
|
2121
|
+
noteEl;
|
|
2122
|
+
countdownTicker = null;
|
|
2123
|
+
microphone = null;
|
|
2124
|
+
micError = null;
|
|
1945
2125
|
// Callbacks
|
|
1946
2126
|
onTranscription;
|
|
1947
2127
|
onResponse;
|
|
@@ -1949,6 +2129,12 @@ var ConversationalPanel = class {
|
|
|
1949
2129
|
onStop;
|
|
1950
2130
|
onError;
|
|
1951
2131
|
onToolCall;
|
|
2132
|
+
/**
|
|
2133
|
+
* The call ended without the visitor pressing End: a time limit, the silence timeout, the server
|
|
2134
|
+
* closing the session, or the connection being lost. Receives the message the panel shows, so a
|
|
2135
|
+
* page that renders its own chrome (and hides this panel's curtain) can show it too.
|
|
2136
|
+
*/
|
|
2137
|
+
onEnded;
|
|
1952
2138
|
constructor(cfg) {
|
|
1953
2139
|
this.cfg = cfg;
|
|
1954
2140
|
this.container = cfg.container;
|
|
@@ -1989,6 +2175,8 @@ var ConversationalPanel = class {
|
|
|
1989
2175
|
</div>
|
|
1990
2176
|
<div class="cv-header">
|
|
1991
2177
|
<h2 class="cv-title">${this.esc(this.cfg.title || "Voice Chat")}</h2>
|
|
2178
|
+
<div class="cv-countdown" aria-live="off"></div>
|
|
2179
|
+
<div class="cv-note" role="status"></div>
|
|
1992
2180
|
</div>
|
|
1993
2181
|
<div class="cv-visualizer-wrap">
|
|
1994
2182
|
<canvas class="cv-canvas"></canvas>
|
|
@@ -2024,6 +2212,8 @@ var ConversationalPanel = class {
|
|
|
2024
2212
|
this.muteSvg = this.muteBtn.querySelector("svg");
|
|
2025
2213
|
this.stopBtn = this.el.querySelector(".cv-btn--end");
|
|
2026
2214
|
this.visualizerWrap = this.el.querySelector(".cv-visualizer-wrap");
|
|
2215
|
+
this.countdownEl = this.el.querySelector(".cv-countdown");
|
|
2216
|
+
this.noteEl = this.el.querySelector(".cv-note");
|
|
2027
2217
|
this.startBtn.addEventListener("click", () => this.start());
|
|
2028
2218
|
this.stopBtn.addEventListener("click", () => this.stop());
|
|
2029
2219
|
this.muteBtn.addEventListener("click", () => this.toggleMute());
|
|
@@ -2033,14 +2223,23 @@ var ConversationalPanel = class {
|
|
|
2033
2223
|
d.textContent = s;
|
|
2034
2224
|
return d.innerHTML;
|
|
2035
2225
|
}
|
|
2036
|
-
/**
|
|
2226
|
+
/**
|
|
2227
|
+
* Start the conversation (called when user clicks "Start Conversation" or externally). Call it from
|
|
2228
|
+
* inside the click or tap, before any await: it asks for the microphone there. Resolves true once the
|
|
2229
|
+
* call is live, false when it could not start (the reason is shown, and passed to onError).
|
|
2230
|
+
*/
|
|
2037
2231
|
async start() {
|
|
2038
|
-
if (this.isRunning) return;
|
|
2232
|
+
if (this.isRunning) return false;
|
|
2039
2233
|
if (this._locked) {
|
|
2040
2234
|
this.showError("This session has ended. Refresh the page to start a new one.");
|
|
2041
|
-
return;
|
|
2235
|
+
return false;
|
|
2042
2236
|
}
|
|
2043
2237
|
this.isRunning = true;
|
|
2238
|
+
this.microphone = requestMicrophone();
|
|
2239
|
+
this.micError = null;
|
|
2240
|
+
this.microphone.catch((e) => {
|
|
2241
|
+
this.micError = e;
|
|
2242
|
+
});
|
|
2044
2243
|
this.startBtn.disabled = true;
|
|
2045
2244
|
this.errorEl.classList.remove("is-visible");
|
|
2046
2245
|
this.playConnectingSound();
|
|
@@ -2071,7 +2270,12 @@ var ConversationalPanel = class {
|
|
|
2071
2270
|
this.el.classList.remove("cv-is-speaking", "cv-is-thinking");
|
|
2072
2271
|
if (status === "speaking") this.el.classList.add("cv-is-speaking");
|
|
2073
2272
|
else if (status === "thinking") this.el.classList.add("cv-is-thinking");
|
|
2273
|
+
this.setNote(status === "reconnecting" ? "Reconnecting\u2026" : "");
|
|
2274
|
+
if (status === "disconnected" && this.isRunning) {
|
|
2275
|
+
this.end("The call has ended.");
|
|
2276
|
+
}
|
|
2074
2277
|
},
|
|
2278
|
+
onSessionLimit: (seconds) => this.startCountdown(seconds),
|
|
2075
2279
|
onTranscription: (text) => {
|
|
2076
2280
|
this._lastSpeechTime = Date.now();
|
|
2077
2281
|
this.onTranscription?.(text);
|
|
@@ -2086,9 +2290,9 @@ var ConversationalPanel = class {
|
|
|
2086
2290
|
onError: (err) => {
|
|
2087
2291
|
if (!this.isRunning) return;
|
|
2088
2292
|
this.onError?.(err);
|
|
2089
|
-
this.
|
|
2090
|
-
|
|
2091
|
-
|
|
2293
|
+
this.end(err?.code === "audio.microphone_unavailable" ? microphoneMessage(err) : err?.code === "session.ended" && err?.message ? err.message : "Connection issue. Try again in a moment.");
|
|
2294
|
+
},
|
|
2295
|
+
microphone: this.microphone
|
|
2092
2296
|
});
|
|
2093
2297
|
this.agent = agentHandle;
|
|
2094
2298
|
visualizer.setAudioClient(agentHandle);
|
|
@@ -2098,6 +2302,7 @@ var ConversationalPanel = class {
|
|
|
2098
2302
|
} catch (_) {
|
|
2099
2303
|
}
|
|
2100
2304
|
const ok = await agentHandle.connect();
|
|
2305
|
+
if (!this.isRunning) return false;
|
|
2101
2306
|
if (ok) {
|
|
2102
2307
|
this.fadeOutConnectingSound();
|
|
2103
2308
|
const wrap = this.visualizerWrap;
|
|
@@ -2111,19 +2316,21 @@ var ConversationalPanel = class {
|
|
|
2111
2316
|
this.startTimer();
|
|
2112
2317
|
this.onStart?.();
|
|
2113
2318
|
if (window.lucide) window.lucide.createIcons();
|
|
2114
|
-
|
|
2115
|
-
this.fadeOutConnectingSound();
|
|
2116
|
-
this.playErrorTone();
|
|
2117
|
-
this.curtain.classList.remove("is-up");
|
|
2118
|
-
this.showError("Could not connect. Please try again.");
|
|
2119
|
-
this.isRunning = false;
|
|
2319
|
+
return true;
|
|
2120
2320
|
}
|
|
2321
|
+
this.fadeOutConnectingSound();
|
|
2322
|
+
this.playErrorTone();
|
|
2323
|
+
const micError = this.micError;
|
|
2324
|
+
this.stop();
|
|
2325
|
+
this.showError(micError ? microphoneMessage(micError) : "Could not connect. Please try again.");
|
|
2326
|
+
return false;
|
|
2121
2327
|
} catch (err) {
|
|
2122
2328
|
this.fadeOutConnectingSound();
|
|
2123
2329
|
this.playErrorTone();
|
|
2124
2330
|
console.error("ConversationalPanel start error:", err);
|
|
2125
2331
|
this.stop();
|
|
2126
2332
|
this.showError("Something went wrong. Please try again.");
|
|
2333
|
+
return false;
|
|
2127
2334
|
} finally {
|
|
2128
2335
|
this.startBtn.disabled = false;
|
|
2129
2336
|
}
|
|
@@ -2141,10 +2348,16 @@ var ConversationalPanel = class {
|
|
|
2141
2348
|
}
|
|
2142
2349
|
this.agent = null;
|
|
2143
2350
|
}
|
|
2351
|
+
const mic = this.microphone;
|
|
2352
|
+
this.microphone = null;
|
|
2353
|
+
mic?.then((s) => s.getTracks().forEach((t) => t.stop()), () => {
|
|
2354
|
+
});
|
|
2144
2355
|
if (this.timerTicker) {
|
|
2145
2356
|
clearInterval(this.timerTicker);
|
|
2146
2357
|
this.timerTicker = null;
|
|
2147
2358
|
}
|
|
2359
|
+
this.stopCountdown();
|
|
2360
|
+
this.setNote("");
|
|
2148
2361
|
if (this.visualizer) {
|
|
2149
2362
|
this.visualizer.stop();
|
|
2150
2363
|
this.visualizer = null;
|
|
@@ -2220,6 +2433,38 @@ var ConversationalPanel = class {
|
|
|
2220
2433
|
this.errorEl.classList.remove("is-visible");
|
|
2221
2434
|
}, 5e3);
|
|
2222
2435
|
}
|
|
2436
|
+
/** End the call for a reason other than the End button, and say why. */
|
|
2437
|
+
end(message) {
|
|
2438
|
+
this.stop();
|
|
2439
|
+
this.showError(message);
|
|
2440
|
+
this.onEnded?.(message);
|
|
2441
|
+
}
|
|
2442
|
+
/** Show how long the session may still run, as announced by the server. */
|
|
2443
|
+
startCountdown(seconds) {
|
|
2444
|
+
this.stopCountdown();
|
|
2445
|
+
const endsAt = Date.now() + seconds * 1e3;
|
|
2446
|
+
const render = () => {
|
|
2447
|
+
const left = Math.max(0, Math.round((endsAt - Date.now()) / 1e3));
|
|
2448
|
+
this.countdownEl.textContent = `${Math.floor(left / 60)}:${String(left % 60).padStart(2, "0")} left`;
|
|
2449
|
+
this.countdownEl.classList.toggle("is-low", left <= 30);
|
|
2450
|
+
if (left === 0) this.stopCountdown(false);
|
|
2451
|
+
};
|
|
2452
|
+
this.countdownEl.classList.add("is-visible");
|
|
2453
|
+
render();
|
|
2454
|
+
this.countdownTicker = window.setInterval(render, 1e3);
|
|
2455
|
+
}
|
|
2456
|
+
stopCountdown(hide = true) {
|
|
2457
|
+
if (this.countdownTicker) {
|
|
2458
|
+
clearInterval(this.countdownTicker);
|
|
2459
|
+
this.countdownTicker = null;
|
|
2460
|
+
}
|
|
2461
|
+
if (hide) this.countdownEl?.classList.remove("is-visible", "is-low");
|
|
2462
|
+
}
|
|
2463
|
+
setNote(text) {
|
|
2464
|
+
if (!this.noteEl) return;
|
|
2465
|
+
this.noteEl.textContent = text;
|
|
2466
|
+
this.noteEl.classList.toggle("is-visible", Boolean(text));
|
|
2467
|
+
}
|
|
2223
2468
|
/** Destroy the component, removing all DOM and stopping any active session */
|
|
2224
2469
|
destroy() {
|
|
2225
2470
|
this.stop();
|
|
@@ -2235,14 +2480,12 @@ var ConversationalPanel = class {
|
|
|
2235
2480
|
elapsed++;
|
|
2236
2481
|
if (elapsed >= maxDur) {
|
|
2237
2482
|
this._locked = true;
|
|
2238
|
-
this.
|
|
2239
|
-
this.showError("Tiempo l\xEDmite alcanzado.");
|
|
2483
|
+
this.end("Time limit reached.");
|
|
2240
2484
|
return;
|
|
2241
2485
|
}
|
|
2242
2486
|
if (Date.now() - this._lastSpeechTime > silentMax * 1e3) {
|
|
2243
2487
|
this._locked = true;
|
|
2244
|
-
this.
|
|
2245
|
-
this.showError("Sesi\xF3n finalizada por inactividad.");
|
|
2488
|
+
this.end("The call ended after a long silence.");
|
|
2246
2489
|
}
|
|
2247
2490
|
}, 1e3);
|
|
2248
2491
|
}
|
|
@@ -2332,9 +2575,11 @@ export {
|
|
|
2332
2575
|
calculateRMS,
|
|
2333
2576
|
float32ToPcm16,
|
|
2334
2577
|
isRetryable,
|
|
2578
|
+
microphoneMessage,
|
|
2335
2579
|
normalizeAudio,
|
|
2336
2580
|
pcm16ToBytes,
|
|
2337
2581
|
pcm16ToFloat32,
|
|
2582
|
+
requestMicrophone,
|
|
2338
2583
|
resample,
|
|
2339
2584
|
resampleWithAntiAliasing,
|
|
2340
2585
|
simpleConversation,
|