@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.js
CHANGED
|
@@ -38,9 +38,11 @@ __export(index_exports, {
|
|
|
38
38
|
calculateRMS: () => calculateRMS,
|
|
39
39
|
float32ToPcm16: () => float32ToPcm16,
|
|
40
40
|
isRetryable: () => isRetryable,
|
|
41
|
+
microphoneMessage: () => microphoneMessage,
|
|
41
42
|
normalizeAudio: () => normalizeAudio,
|
|
42
43
|
pcm16ToBytes: () => pcm16ToBytes,
|
|
43
44
|
pcm16ToFloat32: () => pcm16ToFloat32,
|
|
45
|
+
requestMicrophone: () => requestMicrophone,
|
|
44
46
|
resample: () => resample,
|
|
45
47
|
resampleWithAntiAliasing: () => resampleWithAntiAliasing,
|
|
46
48
|
simpleConversation: () => simpleConversation,
|
|
@@ -130,6 +132,8 @@ function isRetryableCode(code) {
|
|
|
130
132
|
"auth.missing_key",
|
|
131
133
|
"auth.invalid_key",
|
|
132
134
|
"auth.time_limited",
|
|
135
|
+
"session.ended",
|
|
136
|
+
"audio.microphone_unavailable",
|
|
133
137
|
"validation.invalid_voice",
|
|
134
138
|
"validation.invalid_language",
|
|
135
139
|
"validation.text_too_long",
|
|
@@ -284,6 +288,36 @@ var StreamResampler = class {
|
|
|
284
288
|
};
|
|
285
289
|
|
|
286
290
|
// src/browser-audio.ts
|
|
291
|
+
function requestMicrophone(constraints = {}) {
|
|
292
|
+
if (typeof navigator === "undefined") {
|
|
293
|
+
return Promise.reject(new Error("Microphone capture needs a browser"));
|
|
294
|
+
}
|
|
295
|
+
const session = navigator.audioSession;
|
|
296
|
+
if (session && session.type !== "auto" && session.type !== "play-and-record") {
|
|
297
|
+
try {
|
|
298
|
+
session.type = "auto";
|
|
299
|
+
} catch (_) {
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (!navigator.mediaDevices?.getUserMedia) {
|
|
303
|
+
return Promise.reject(new Error("Microphone capture is not available here (it needs HTTPS)"));
|
|
304
|
+
}
|
|
305
|
+
return navigator.mediaDevices.getUserMedia({
|
|
306
|
+
audio: {
|
|
307
|
+
autoGainControl: constraints.autoGainControl ?? true,
|
|
308
|
+
echoCancellation: constraints.echoCancellation ?? true,
|
|
309
|
+
noiseSuppression: constraints.noiseSuppression ?? true
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
async function resumeWithin(ctx, ms) {
|
|
314
|
+
if (ctx.state === "running") return;
|
|
315
|
+
await Promise.race([
|
|
316
|
+
ctx.resume().catch(() => {
|
|
317
|
+
}),
|
|
318
|
+
new Promise((r) => setTimeout(r, ms))
|
|
319
|
+
]);
|
|
320
|
+
}
|
|
287
321
|
var BrowserAudioManager = class {
|
|
288
322
|
audioContext = null;
|
|
289
323
|
mediaStreamAudioSourceNode = null;
|
|
@@ -305,6 +339,12 @@ var BrowserAudioManager = class {
|
|
|
305
339
|
autoGainControl;
|
|
306
340
|
echoCancellation;
|
|
307
341
|
noiseSuppression;
|
|
342
|
+
keepAwake;
|
|
343
|
+
providedMicrophone;
|
|
344
|
+
// Screen wake lock, held while the microphone is live. Browsers release it whenever the page is
|
|
345
|
+
// hidden, so it is taken again when the page becomes visible.
|
|
346
|
+
wakeLock = null;
|
|
347
|
+
visibilityHandler = null;
|
|
308
348
|
// Callbacks
|
|
309
349
|
onAudioInput;
|
|
310
350
|
onInputError;
|
|
@@ -317,6 +357,10 @@ var BrowserAudioManager = class {
|
|
|
317
357
|
this.autoGainControl = config.autoGainControl ?? true;
|
|
318
358
|
this.echoCancellation = config.echoCancellation ?? true;
|
|
319
359
|
this.noiseSuppression = config.noiseSuppression ?? true;
|
|
360
|
+
this.keepAwake = config.keepAwake ?? true;
|
|
361
|
+
this.providedMicrophone = config.microphone ?? null;
|
|
362
|
+
if (this.providedMicrophone instanceof Promise) this.providedMicrophone.catch(() => {
|
|
363
|
+
});
|
|
320
364
|
this.onInputError = config.onInputError;
|
|
321
365
|
}
|
|
322
366
|
/**
|
|
@@ -332,9 +376,8 @@ var BrowserAudioManager = class {
|
|
|
332
376
|
if (!this.audioContext) {
|
|
333
377
|
throw new Error("Failed to initialize AudioContext");
|
|
334
378
|
}
|
|
335
|
-
if (this.audioContext.state
|
|
336
|
-
await this.audioContext
|
|
337
|
-
console.log("\u{1F442} AudioContext resumed");
|
|
379
|
+
if (this.audioContext.state !== "running") {
|
|
380
|
+
await resumeWithin(this.audioContext, 500);
|
|
338
381
|
}
|
|
339
382
|
if (analyserConfig?.enabled !== false) {
|
|
340
383
|
this.analyserNode = this.audioContext.createAnalyser();
|
|
@@ -348,10 +391,17 @@ var BrowserAudioManager = class {
|
|
|
348
391
|
if (!this.audioContext) {
|
|
349
392
|
await this.init();
|
|
350
393
|
}
|
|
394
|
+
if (this.isListening && this.mediaStream) {
|
|
395
|
+
this.onAudioInput = onAudioInput;
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
351
398
|
try {
|
|
352
399
|
this.onAudioInput = onAudioInput;
|
|
353
400
|
this.isListening = true;
|
|
354
|
-
|
|
401
|
+
const provided = this.providedMicrophone;
|
|
402
|
+
this.providedMicrophone = null;
|
|
403
|
+
const stream = provided ? await provided : null;
|
|
404
|
+
this.mediaStream = stream && stream.getAudioTracks().some((t) => t.readyState === "live") ? stream : await navigator.mediaDevices.getUserMedia({
|
|
355
405
|
audio: {
|
|
356
406
|
autoGainControl: this.autoGainControl,
|
|
357
407
|
echoCancellation: this.echoCancellation,
|
|
@@ -382,7 +432,9 @@ var BrowserAudioManager = class {
|
|
|
382
432
|
this._processAudioInput(event);
|
|
383
433
|
};
|
|
384
434
|
console.log("\u{1F3A4} Microphone started");
|
|
435
|
+
this.holdScreenAwake();
|
|
385
436
|
} catch (error) {
|
|
437
|
+
this.isListening = false;
|
|
386
438
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
387
439
|
if (this.onInputError) this.onInputError(err);
|
|
388
440
|
throw err;
|
|
@@ -420,6 +472,7 @@ var BrowserAudioManager = class {
|
|
|
420
472
|
*/
|
|
421
473
|
stopMicrophone() {
|
|
422
474
|
this.isListening = false;
|
|
475
|
+
this.releaseScreenAwake();
|
|
423
476
|
if (this.mediaStream) {
|
|
424
477
|
this.mediaStream.getTracks().forEach((track) => track.stop());
|
|
425
478
|
this.mediaStream = null;
|
|
@@ -434,6 +487,53 @@ var BrowserAudioManager = class {
|
|
|
434
487
|
}
|
|
435
488
|
console.log("\u{1F3A4} Microphone stopped");
|
|
436
489
|
}
|
|
490
|
+
/**
|
|
491
|
+
* Keep the screen on while the microphone is live, and bring audio back when the page returns
|
|
492
|
+
* to the foreground (iOS leaves the context "interrupted" after the screen locks).
|
|
493
|
+
*/
|
|
494
|
+
holdScreenAwake() {
|
|
495
|
+
if (typeof document === "undefined") return;
|
|
496
|
+
if (!this.visibilityHandler) {
|
|
497
|
+
this.visibilityHandler = () => {
|
|
498
|
+
if (document.visibilityState !== "visible" || !this.isListening) return;
|
|
499
|
+
if (this.audioContext && this.audioContext.state !== "running") {
|
|
500
|
+
this.audioContext.resume().catch(() => {
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
void this.requestWakeLock();
|
|
504
|
+
};
|
|
505
|
+
document.addEventListener("visibilitychange", this.visibilityHandler);
|
|
506
|
+
}
|
|
507
|
+
void this.requestWakeLock();
|
|
508
|
+
}
|
|
509
|
+
async requestWakeLock() {
|
|
510
|
+
if (!this.keepAwake || this.wakeLock || typeof navigator === "undefined") return;
|
|
511
|
+
const api = navigator.wakeLock;
|
|
512
|
+
if (!api?.request || document.visibilityState !== "visible") return;
|
|
513
|
+
try {
|
|
514
|
+
const lock = await api.request("screen");
|
|
515
|
+
if (!this.isListening) {
|
|
516
|
+
await lock.release().catch(() => {
|
|
517
|
+
});
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
this.wakeLock = lock;
|
|
521
|
+
lock.addEventListener?.("release", () => {
|
|
522
|
+
if (this.wakeLock === lock) this.wakeLock = null;
|
|
523
|
+
});
|
|
524
|
+
} catch (_) {
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
releaseScreenAwake() {
|
|
528
|
+
if (this.visibilityHandler && typeof document !== "undefined") {
|
|
529
|
+
document.removeEventListener("visibilitychange", this.visibilityHandler);
|
|
530
|
+
this.visibilityHandler = null;
|
|
531
|
+
}
|
|
532
|
+
const lock = this.wakeLock;
|
|
533
|
+
this.wakeLock = null;
|
|
534
|
+
lock?.release().catch(() => {
|
|
535
|
+
});
|
|
536
|
+
}
|
|
437
537
|
/**
|
|
438
538
|
* Play back audio received from the server
|
|
439
539
|
* @param pcm16Data Int16 PCM audio data at SPEAKER_SAMPLE_RATE
|
|
@@ -569,6 +669,13 @@ var BrowserAudioManager = class {
|
|
|
569
669
|
this.analyserNode.disconnect();
|
|
570
670
|
this.analyserNode = null;
|
|
571
671
|
}
|
|
672
|
+
if (this.audioContext) {
|
|
673
|
+
const ctx = this.audioContext;
|
|
674
|
+
this.audioContext = null;
|
|
675
|
+
this.nextPlaybackTime = 0;
|
|
676
|
+
ctx.close().catch(() => {
|
|
677
|
+
});
|
|
678
|
+
}
|
|
572
679
|
}
|
|
573
680
|
/**
|
|
574
681
|
* Get current audio context state
|
|
@@ -633,6 +740,7 @@ async function fetchJson(url, timeoutMs = 1e4) {
|
|
|
633
740
|
});
|
|
634
741
|
}
|
|
635
742
|
}
|
|
743
|
+
var RECONNECTABLE_CLOSE_CODES = /* @__PURE__ */ new Set([1001, 1006, 1011, 1012, 1013, 1014]);
|
|
636
744
|
function base64ToUint8Array(base64) {
|
|
637
745
|
const binaryString = atob(base64);
|
|
638
746
|
const bytes = new Uint8Array(binaryString.length);
|
|
@@ -712,6 +820,9 @@ var VoiceAgentClient = class {
|
|
|
712
820
|
reconnectAttempts = 0;
|
|
713
821
|
maxReconnectAttempts = 5;
|
|
714
822
|
reconnectTimer = null;
|
|
823
|
+
// The microphone is started once per session of this client, not once per socket: a reconnect
|
|
824
|
+
// reuses it. Starting it again on every open ran two capture pipelines into one resampler.
|
|
825
|
+
micStarted = false;
|
|
715
826
|
// Runtime state
|
|
716
827
|
serverPlaybackRate = 44100;
|
|
717
828
|
serverInputRate = 16e3;
|
|
@@ -782,15 +893,35 @@ var VoiceAgentClient = class {
|
|
|
782
893
|
this.isConnected = true;
|
|
783
894
|
this.reconnectAttempts = 0;
|
|
784
895
|
this.reconnecting = false;
|
|
896
|
+
this.currentGeneration = 0;
|
|
785
897
|
console.log("\u2705 Connected to voice agent!");
|
|
786
898
|
sdkTrace("ws.open");
|
|
787
899
|
this.sendConfig();
|
|
788
|
-
if (this.audioManager) {
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
this.
|
|
900
|
+
if (this.audioManager && !this.micStarted) {
|
|
901
|
+
try {
|
|
902
|
+
await this.audioManager.startMicrophone((data) => {
|
|
903
|
+
if (this.isConnected) {
|
|
904
|
+
this.sendAudio(data);
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
} catch (e) {
|
|
908
|
+
const name = e?.name || "Error";
|
|
909
|
+
const error = new LokutorError(
|
|
910
|
+
"audio.microphone_unavailable",
|
|
911
|
+
name === "NotAllowedError" || name === "SecurityError" ? "Microphone access was not allowed." : "The microphone could not be started.",
|
|
912
|
+
{ detail: `${name}: ${e?.message ?? e}`, original: e, retryable: false }
|
|
913
|
+
);
|
|
914
|
+
sdkTrace("mic.failed", { name });
|
|
915
|
+
settle(() => reject(error));
|
|
916
|
+
this.isUserDisconnect = true;
|
|
917
|
+
try {
|
|
918
|
+
this.ws?.close();
|
|
919
|
+
} catch (_) {
|
|
792
920
|
}
|
|
793
|
-
|
|
921
|
+
this.emit("error", error);
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
this.micStarted = true;
|
|
794
925
|
}
|
|
795
926
|
settle(() => resolve(true));
|
|
796
927
|
};
|
|
@@ -841,6 +972,17 @@ var VoiceAgentClient = class {
|
|
|
841
972
|
console.error(" URL:", this.serverUrl.replace(/api_key=[^&]+/, "api_key=***"));
|
|
842
973
|
}
|
|
843
974
|
console.log(`\u{1F50C} WebSocket closed \u2014 code: ${event.code}, reason: "${event.reason || "none"}", clean: ${event.wasClean}`);
|
|
975
|
+
if (!this.isUserDisconnect && !RECONNECTABLE_CLOSE_CODES.has(event.code)) {
|
|
976
|
+
const ended = new LokutorError("session.ended", event.reason || "The session was ended by the server.", {
|
|
977
|
+
detail: `close code ${event.code}`,
|
|
978
|
+
retryable: false
|
|
979
|
+
});
|
|
980
|
+
console.log(`Session ended by the server (code ${event.code})`);
|
|
981
|
+
this.emit("ended", { code: event.code, reason: event.reason || "" });
|
|
982
|
+
if (this.onError) this.onError(ended);
|
|
983
|
+
if (this.onStatus) this.onStatus("disconnected");
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
844
986
|
if (!this.isUserDisconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
845
987
|
this.reconnecting = true;
|
|
846
988
|
this.reconnectAttempts++;
|
|
@@ -1030,11 +1172,18 @@ var VoiceAgentClient = class {
|
|
|
1030
1172
|
detail: backendDetail,
|
|
1031
1173
|
retryable: backendRetryable
|
|
1032
1174
|
});
|
|
1033
|
-
if (this.onError) this.onError(error);
|
|
1034
1175
|
this.emit("error", error);
|
|
1035
1176
|
console.error(`\u274C Server error: [${error.code}] ${error.message}`);
|
|
1036
1177
|
break;
|
|
1037
1178
|
}
|
|
1179
|
+
case "session_limit": {
|
|
1180
|
+
const seconds = Number(msg.data?.seconds ?? msg.seconds);
|
|
1181
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
1182
|
+
const limit = { seconds, reason: msg.data?.reason ?? msg.reason };
|
|
1183
|
+
this.emit("session_limit", limit);
|
|
1184
|
+
}
|
|
1185
|
+
break;
|
|
1186
|
+
}
|
|
1038
1187
|
case "tool_call":
|
|
1039
1188
|
this.emit("tool_call", { name: msg.name, arguments: msg.arguments });
|
|
1040
1189
|
console.log(`\u{1F6E0}\uFE0F Tool Call: ${msg.name}(${msg.arguments})`);
|
|
@@ -1108,6 +1257,7 @@ var VoiceAgentClient = class {
|
|
|
1108
1257
|
if (this.audioManager) {
|
|
1109
1258
|
this.audioManager.cleanup();
|
|
1110
1259
|
}
|
|
1260
|
+
this.micStarted = false;
|
|
1111
1261
|
this.isConnected = false;
|
|
1112
1262
|
this.configConfirmed = false;
|
|
1113
1263
|
this.reconnecting = false;
|
|
@@ -1757,6 +1907,22 @@ var PANEL_CSS = (
|
|
|
1757
1907
|
margin-bottom: auto;
|
|
1758
1908
|
padding-top: 1.5rem;
|
|
1759
1909
|
}
|
|
1910
|
+
.cv-countdown, .cv-note {
|
|
1911
|
+
display: none;
|
|
1912
|
+
margin-top: 0.4rem;
|
|
1913
|
+
padding: 0.15rem 0.6rem;
|
|
1914
|
+
border-radius: 999px;
|
|
1915
|
+
border: 1px solid var(--cv-ui-border, rgba(255,255,255,0.1));
|
|
1916
|
+
background: var(--cv-ui-bg, rgba(255,255,255,0.05));
|
|
1917
|
+
color: var(--cv-text-dim, rgba(255,255,255,0.6));
|
|
1918
|
+
font-size: 0.75rem;
|
|
1919
|
+
font-variant-numeric: tabular-nums;
|
|
1920
|
+
width: fit-content;
|
|
1921
|
+
margin-left: auto;
|
|
1922
|
+
margin-right: auto;
|
|
1923
|
+
}
|
|
1924
|
+
.cv-countdown.is-visible, .cv-note.is-visible { display: block; }
|
|
1925
|
+
.cv-countdown.is-low { color: var(--cv-accent); border-color: var(--cv-accent); }
|
|
1760
1926
|
.cv-title {
|
|
1761
1927
|
font-size: clamp(1rem, 2.5vw, 1.75rem);
|
|
1762
1928
|
font-weight: 700;
|
|
@@ -1967,6 +2133,17 @@ function injectStyles() {
|
|
|
1967
2133
|
document.head.appendChild(style);
|
|
1968
2134
|
styleInjected = true;
|
|
1969
2135
|
}
|
|
2136
|
+
function microphoneMessage(err) {
|
|
2137
|
+
const name = String(err?.original?.name ?? err?.name ?? "");
|
|
2138
|
+
const detail = String(err?.detail ?? err?.message ?? "");
|
|
2139
|
+
if (/NotAllowedError|SecurityError/.test(name + " " + detail)) {
|
|
2140
|
+
return "Microphone access is blocked. Allow it for this site in your browser settings, then try again.";
|
|
2141
|
+
}
|
|
2142
|
+
if (/NotFoundError|OverconstrainedError/.test(name + " " + detail)) {
|
|
2143
|
+
return "No microphone was found on this device.";
|
|
2144
|
+
}
|
|
2145
|
+
return "The microphone could not start. Please try again.";
|
|
2146
|
+
}
|
|
1970
2147
|
var ConversationalPanel = class {
|
|
1971
2148
|
cfg;
|
|
1972
2149
|
container;
|
|
@@ -1993,6 +2170,11 @@ var ConversationalPanel = class {
|
|
|
1993
2170
|
muteSvg;
|
|
1994
2171
|
stopBtn;
|
|
1995
2172
|
visualizerWrap;
|
|
2173
|
+
countdownEl;
|
|
2174
|
+
noteEl;
|
|
2175
|
+
countdownTicker = null;
|
|
2176
|
+
microphone = null;
|
|
2177
|
+
micError = null;
|
|
1996
2178
|
// Callbacks
|
|
1997
2179
|
onTranscription;
|
|
1998
2180
|
onResponse;
|
|
@@ -2000,6 +2182,12 @@ var ConversationalPanel = class {
|
|
|
2000
2182
|
onStop;
|
|
2001
2183
|
onError;
|
|
2002
2184
|
onToolCall;
|
|
2185
|
+
/**
|
|
2186
|
+
* The call ended without the visitor pressing End: a time limit, the silence timeout, the server
|
|
2187
|
+
* closing the session, or the connection being lost. Receives the message the panel shows, so a
|
|
2188
|
+
* page that renders its own chrome (and hides this panel's curtain) can show it too.
|
|
2189
|
+
*/
|
|
2190
|
+
onEnded;
|
|
2003
2191
|
constructor(cfg) {
|
|
2004
2192
|
this.cfg = cfg;
|
|
2005
2193
|
this.container = cfg.container;
|
|
@@ -2040,6 +2228,8 @@ var ConversationalPanel = class {
|
|
|
2040
2228
|
</div>
|
|
2041
2229
|
<div class="cv-header">
|
|
2042
2230
|
<h2 class="cv-title">${this.esc(this.cfg.title || "Voice Chat")}</h2>
|
|
2231
|
+
<div class="cv-countdown" aria-live="off"></div>
|
|
2232
|
+
<div class="cv-note" role="status"></div>
|
|
2043
2233
|
</div>
|
|
2044
2234
|
<div class="cv-visualizer-wrap">
|
|
2045
2235
|
<canvas class="cv-canvas"></canvas>
|
|
@@ -2075,6 +2265,8 @@ var ConversationalPanel = class {
|
|
|
2075
2265
|
this.muteSvg = this.muteBtn.querySelector("svg");
|
|
2076
2266
|
this.stopBtn = this.el.querySelector(".cv-btn--end");
|
|
2077
2267
|
this.visualizerWrap = this.el.querySelector(".cv-visualizer-wrap");
|
|
2268
|
+
this.countdownEl = this.el.querySelector(".cv-countdown");
|
|
2269
|
+
this.noteEl = this.el.querySelector(".cv-note");
|
|
2078
2270
|
this.startBtn.addEventListener("click", () => this.start());
|
|
2079
2271
|
this.stopBtn.addEventListener("click", () => this.stop());
|
|
2080
2272
|
this.muteBtn.addEventListener("click", () => this.toggleMute());
|
|
@@ -2084,14 +2276,23 @@ var ConversationalPanel = class {
|
|
|
2084
2276
|
d.textContent = s;
|
|
2085
2277
|
return d.innerHTML;
|
|
2086
2278
|
}
|
|
2087
|
-
/**
|
|
2279
|
+
/**
|
|
2280
|
+
* Start the conversation (called when user clicks "Start Conversation" or externally). Call it from
|
|
2281
|
+
* inside the click or tap, before any await: it asks for the microphone there. Resolves true once the
|
|
2282
|
+
* call is live, false when it could not start (the reason is shown, and passed to onError).
|
|
2283
|
+
*/
|
|
2088
2284
|
async start() {
|
|
2089
|
-
if (this.isRunning) return;
|
|
2285
|
+
if (this.isRunning) return false;
|
|
2090
2286
|
if (this._locked) {
|
|
2091
2287
|
this.showError("This session has ended. Refresh the page to start a new one.");
|
|
2092
|
-
return;
|
|
2288
|
+
return false;
|
|
2093
2289
|
}
|
|
2094
2290
|
this.isRunning = true;
|
|
2291
|
+
this.microphone = requestMicrophone();
|
|
2292
|
+
this.micError = null;
|
|
2293
|
+
this.microphone.catch((e) => {
|
|
2294
|
+
this.micError = e;
|
|
2295
|
+
});
|
|
2095
2296
|
this.startBtn.disabled = true;
|
|
2096
2297
|
this.errorEl.classList.remove("is-visible");
|
|
2097
2298
|
this.playConnectingSound();
|
|
@@ -2122,7 +2323,12 @@ var ConversationalPanel = class {
|
|
|
2122
2323
|
this.el.classList.remove("cv-is-speaking", "cv-is-thinking");
|
|
2123
2324
|
if (status === "speaking") this.el.classList.add("cv-is-speaking");
|
|
2124
2325
|
else if (status === "thinking") this.el.classList.add("cv-is-thinking");
|
|
2326
|
+
this.setNote(status === "reconnecting" ? "Reconnecting\u2026" : "");
|
|
2327
|
+
if (status === "disconnected" && this.isRunning) {
|
|
2328
|
+
this.end("The call has ended.");
|
|
2329
|
+
}
|
|
2125
2330
|
},
|
|
2331
|
+
onSessionLimit: (seconds) => this.startCountdown(seconds),
|
|
2126
2332
|
onTranscription: (text) => {
|
|
2127
2333
|
this._lastSpeechTime = Date.now();
|
|
2128
2334
|
this.onTranscription?.(text);
|
|
@@ -2137,9 +2343,9 @@ var ConversationalPanel = class {
|
|
|
2137
2343
|
onError: (err) => {
|
|
2138
2344
|
if (!this.isRunning) return;
|
|
2139
2345
|
this.onError?.(err);
|
|
2140
|
-
this.
|
|
2141
|
-
|
|
2142
|
-
|
|
2346
|
+
this.end(err?.code === "audio.microphone_unavailable" ? microphoneMessage(err) : err?.code === "session.ended" && err?.message ? err.message : "Connection issue. Try again in a moment.");
|
|
2347
|
+
},
|
|
2348
|
+
microphone: this.microphone
|
|
2143
2349
|
});
|
|
2144
2350
|
this.agent = agentHandle;
|
|
2145
2351
|
visualizer.setAudioClient(agentHandle);
|
|
@@ -2149,6 +2355,7 @@ var ConversationalPanel = class {
|
|
|
2149
2355
|
} catch (_) {
|
|
2150
2356
|
}
|
|
2151
2357
|
const ok = await agentHandle.connect();
|
|
2358
|
+
if (!this.isRunning) return false;
|
|
2152
2359
|
if (ok) {
|
|
2153
2360
|
this.fadeOutConnectingSound();
|
|
2154
2361
|
const wrap = this.visualizerWrap;
|
|
@@ -2162,19 +2369,21 @@ var ConversationalPanel = class {
|
|
|
2162
2369
|
this.startTimer();
|
|
2163
2370
|
this.onStart?.();
|
|
2164
2371
|
if (window.lucide) window.lucide.createIcons();
|
|
2165
|
-
|
|
2166
|
-
this.fadeOutConnectingSound();
|
|
2167
|
-
this.playErrorTone();
|
|
2168
|
-
this.curtain.classList.remove("is-up");
|
|
2169
|
-
this.showError("Could not connect. Please try again.");
|
|
2170
|
-
this.isRunning = false;
|
|
2372
|
+
return true;
|
|
2171
2373
|
}
|
|
2374
|
+
this.fadeOutConnectingSound();
|
|
2375
|
+
this.playErrorTone();
|
|
2376
|
+
const micError = this.micError;
|
|
2377
|
+
this.stop();
|
|
2378
|
+
this.showError(micError ? microphoneMessage(micError) : "Could not connect. Please try again.");
|
|
2379
|
+
return false;
|
|
2172
2380
|
} catch (err) {
|
|
2173
2381
|
this.fadeOutConnectingSound();
|
|
2174
2382
|
this.playErrorTone();
|
|
2175
2383
|
console.error("ConversationalPanel start error:", err);
|
|
2176
2384
|
this.stop();
|
|
2177
2385
|
this.showError("Something went wrong. Please try again.");
|
|
2386
|
+
return false;
|
|
2178
2387
|
} finally {
|
|
2179
2388
|
this.startBtn.disabled = false;
|
|
2180
2389
|
}
|
|
@@ -2192,10 +2401,16 @@ var ConversationalPanel = class {
|
|
|
2192
2401
|
}
|
|
2193
2402
|
this.agent = null;
|
|
2194
2403
|
}
|
|
2404
|
+
const mic = this.microphone;
|
|
2405
|
+
this.microphone = null;
|
|
2406
|
+
mic?.then((s) => s.getTracks().forEach((t) => t.stop()), () => {
|
|
2407
|
+
});
|
|
2195
2408
|
if (this.timerTicker) {
|
|
2196
2409
|
clearInterval(this.timerTicker);
|
|
2197
2410
|
this.timerTicker = null;
|
|
2198
2411
|
}
|
|
2412
|
+
this.stopCountdown();
|
|
2413
|
+
this.setNote("");
|
|
2199
2414
|
if (this.visualizer) {
|
|
2200
2415
|
this.visualizer.stop();
|
|
2201
2416
|
this.visualizer = null;
|
|
@@ -2271,6 +2486,38 @@ var ConversationalPanel = class {
|
|
|
2271
2486
|
this.errorEl.classList.remove("is-visible");
|
|
2272
2487
|
}, 5e3);
|
|
2273
2488
|
}
|
|
2489
|
+
/** End the call for a reason other than the End button, and say why. */
|
|
2490
|
+
end(message) {
|
|
2491
|
+
this.stop();
|
|
2492
|
+
this.showError(message);
|
|
2493
|
+
this.onEnded?.(message);
|
|
2494
|
+
}
|
|
2495
|
+
/** Show how long the session may still run, as announced by the server. */
|
|
2496
|
+
startCountdown(seconds) {
|
|
2497
|
+
this.stopCountdown();
|
|
2498
|
+
const endsAt = Date.now() + seconds * 1e3;
|
|
2499
|
+
const render = () => {
|
|
2500
|
+
const left = Math.max(0, Math.round((endsAt - Date.now()) / 1e3));
|
|
2501
|
+
this.countdownEl.textContent = `${Math.floor(left / 60)}:${String(left % 60).padStart(2, "0")} left`;
|
|
2502
|
+
this.countdownEl.classList.toggle("is-low", left <= 30);
|
|
2503
|
+
if (left === 0) this.stopCountdown(false);
|
|
2504
|
+
};
|
|
2505
|
+
this.countdownEl.classList.add("is-visible");
|
|
2506
|
+
render();
|
|
2507
|
+
this.countdownTicker = window.setInterval(render, 1e3);
|
|
2508
|
+
}
|
|
2509
|
+
stopCountdown(hide = true) {
|
|
2510
|
+
if (this.countdownTicker) {
|
|
2511
|
+
clearInterval(this.countdownTicker);
|
|
2512
|
+
this.countdownTicker = null;
|
|
2513
|
+
}
|
|
2514
|
+
if (hide) this.countdownEl?.classList.remove("is-visible", "is-low");
|
|
2515
|
+
}
|
|
2516
|
+
setNote(text) {
|
|
2517
|
+
if (!this.noteEl) return;
|
|
2518
|
+
this.noteEl.textContent = text;
|
|
2519
|
+
this.noteEl.classList.toggle("is-visible", Boolean(text));
|
|
2520
|
+
}
|
|
2274
2521
|
/** Destroy the component, removing all DOM and stopping any active session */
|
|
2275
2522
|
destroy() {
|
|
2276
2523
|
this.stop();
|
|
@@ -2286,14 +2533,12 @@ var ConversationalPanel = class {
|
|
|
2286
2533
|
elapsed++;
|
|
2287
2534
|
if (elapsed >= maxDur) {
|
|
2288
2535
|
this._locked = true;
|
|
2289
|
-
this.
|
|
2290
|
-
this.showError("Tiempo l\xEDmite alcanzado.");
|
|
2536
|
+
this.end("Time limit reached.");
|
|
2291
2537
|
return;
|
|
2292
2538
|
}
|
|
2293
2539
|
if (Date.now() - this._lastSpeechTime > silentMax * 1e3) {
|
|
2294
2540
|
this._locked = true;
|
|
2295
|
-
this.
|
|
2296
|
-
this.showError("Sesi\xF3n finalizada por inactividad.");
|
|
2541
|
+
this.end("The call ended after a long silence.");
|
|
2297
2542
|
}
|
|
2298
2543
|
}, 1e3);
|
|
2299
2544
|
}
|
|
@@ -2384,9 +2629,11 @@ var ConversationalPanel = class {
|
|
|
2384
2629
|
calculateRMS,
|
|
2385
2630
|
float32ToPcm16,
|
|
2386
2631
|
isRetryable,
|
|
2632
|
+
microphoneMessage,
|
|
2387
2633
|
normalizeAudio,
|
|
2388
2634
|
pcm16ToBytes,
|
|
2389
2635
|
pcm16ToFloat32,
|
|
2636
|
+
requestMicrophone,
|
|
2390
2637
|
resample,
|
|
2391
2638
|
resampleWithAntiAliasing,
|
|
2392
2639
|
simpleConversation,
|