@voxevolv/client 0.3.8 → 0.3.10
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/CHANGELOG.md +24 -1
- package/README.md +8 -2
- package/dist/browser/index.js +6 -6
- package/dist/browser/index.js.map +3 -3
- package/dist/browser/index.js.sha384 +1 -1
- package/dist/browser/manifest.json +1 -1
- package/dist/diagnostics.d.ts +10 -1
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js +26 -0
- package/dist/diagnostics.js.map +1 -1
- package/dist/index.d.ts +12 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +175 -40
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ConnectionState as LiveKitConnectionState, DataPacket_Kind, DisconnectReason, Room, RoomEvent, RpcError, Track, } from "livekit-client";
|
|
1
|
+
import { ConnectionState as LiveKitConnectionState, DataPacket_Kind, DisconnectReason, LocalAudioTrack, Room, RoomEvent, RpcError, Track, } from "livekit-client";
|
|
2
2
|
import { createClientMessage, CommandError, EventCursor, isCommandAckPayload, isCommandErrorPayload, isContextRejectedPayload, isContextUpdatePayload, isContextUpdatedPayload, isRealtimeEvent, } from "./protocol.js";
|
|
3
3
|
import { ContextDeliveryError, contextRetryDelay, ReplacementContextQueue } from "./context-queue.js";
|
|
4
4
|
import { ClientDiagnosticBuffer, DiagnosticDeliveryError, } from "./diagnostics.js";
|
|
@@ -6,7 +6,7 @@ import { TranscriptStore } from "./transcripts.js";
|
|
|
6
6
|
export { TranscriptStore } from "./transcripts.js";
|
|
7
7
|
export { CommandError, EventCursor, createClientMessage, isCommandAckPayload, isCommandErrorPayload, isContextRejectedPayload, isContextUpdatePayload, isContextUpdatedPayload, isRealtimeEvent, } from "./protocol.js";
|
|
8
8
|
export { decodeAgentSnapshotPayload, decodeClientHelloPayload, decodeClientMessage, decodeCommandAckPayload, decodeCommandErrorPayload, decodeContextRejectedPayload, decodeContextUpdatePayload, decodeContextUpdatedPayload, decodeRealtimeEvent, } from "./generated/protocol-codecs.js";
|
|
9
|
-
export const SDK_VERSION = "0.3.
|
|
9
|
+
export const SDK_VERSION = "0.3.10";
|
|
10
10
|
const CLIENT_HELLO_TIMEOUT_MS = 30_000;
|
|
11
11
|
const DIAGNOSTIC_REPORT_TIMEOUT_MS = 5_000;
|
|
12
12
|
const DEFAULT_STARTUP_ATTEMPTS = [
|
|
@@ -99,9 +99,12 @@ export class RealtimeSession {
|
|
|
99
99
|
startupConnectController = null;
|
|
100
100
|
startupAttemptController = null;
|
|
101
101
|
useManagedMicrophone = true;
|
|
102
|
-
|
|
102
|
+
microphoneEnabled = true;
|
|
103
103
|
applicationAudioTrack = null;
|
|
104
|
+
localApplicationAudioTrack = null;
|
|
104
105
|
connectDefaults;
|
|
106
|
+
recoveryReadiness;
|
|
107
|
+
credentialRefreshGeneration = 0;
|
|
105
108
|
fetcher;
|
|
106
109
|
endEndpoint;
|
|
107
110
|
refreshCredentialsCallback;
|
|
@@ -109,6 +112,11 @@ export class RealtimeSession {
|
|
|
109
112
|
startupRetryOptions;
|
|
110
113
|
lifecycleCredentials;
|
|
111
114
|
diagnostics = new ClientDiagnosticBuffer({ sdkVersion: SDK_VERSION });
|
|
115
|
+
activeContextTransfers = 0;
|
|
116
|
+
activeContextBytes = 0;
|
|
117
|
+
diagnosticLoopTimer;
|
|
118
|
+
diagnosticLoopExpectedAt = 0;
|
|
119
|
+
diagnosticLoopDelayMs;
|
|
112
120
|
diagnosticReportTimeoutMs = DIAGNOSTIC_REPORT_TIMEOUT_MS;
|
|
113
121
|
onDiagnosticVisibilityChange = () => {
|
|
114
122
|
if (globalThis.document?.visibilityState === "hidden")
|
|
@@ -123,6 +131,10 @@ export class RealtimeSession {
|
|
|
123
131
|
? (options.connect ?? {})
|
|
124
132
|
: { ...options.connect, apiBase: normalizeApiBase(options.connect.apiBase) };
|
|
125
133
|
this.apiBase = this.connectDefaults.apiBase ?? this.apiBase;
|
|
134
|
+
this.recoveryReadiness = {
|
|
135
|
+
readyTimeoutMs: this.connectDefaults.readyTimeoutMs ?? 15_000,
|
|
136
|
+
waitForRemoteAudio: this.connectDefaults.waitForRemoteAudio ?? true,
|
|
137
|
+
};
|
|
126
138
|
this.fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
127
139
|
this.endEndpoint = options.endEndpoint ?? null;
|
|
128
140
|
this.refreshCredentialsCallback = options.refreshCredentials ?? null;
|
|
@@ -133,7 +145,34 @@ export class RealtimeSession {
|
|
|
133
145
|
for (const [name, handler] of Object.entries(options.tools ?? {})) {
|
|
134
146
|
this.registerTool(name, handler);
|
|
135
147
|
}
|
|
136
|
-
this.room.registerRpcMethod("voxevolv.tool.invoke",
|
|
148
|
+
this.room.registerRpcMethod("voxevolv.tool.invoke", (invocation) => this.invokeBrowserTool(invocation));
|
|
149
|
+
this.room.registerRpcMethod("voxevolv.diagnostics.flush", async (invocation) => {
|
|
150
|
+
const payload = JSON.parse(invocation.payload);
|
|
151
|
+
if (typeof payload?.call_id !== "string" || payload.call_id.length > 200) {
|
|
152
|
+
throw new RpcError(1500, "Invalid diagnostic request");
|
|
153
|
+
}
|
|
154
|
+
this.recordDiagnostic({
|
|
155
|
+
operation: "tool_rpc", status: "started", stage: "flush_requested",
|
|
156
|
+
call_id: payload.call_id, rpc_request_id: invocation.requestId,
|
|
157
|
+
});
|
|
158
|
+
// HTTP delivery has its own retry/receipt handling; never await it on the RPC channel.
|
|
159
|
+
this.flushDiagnostics("tool_rpc_worker_request");
|
|
160
|
+
return "{}";
|
|
161
|
+
});
|
|
162
|
+
this.bindRoomEvents();
|
|
163
|
+
}
|
|
164
|
+
async invokeBrowserTool(invocation) {
|
|
165
|
+
const receivedAt = performance.now();
|
|
166
|
+
let callId;
|
|
167
|
+
let toolName;
|
|
168
|
+
let slowTimer;
|
|
169
|
+
const record = (entry) => this.recordDiagnostic({
|
|
170
|
+
operation: "tool_rpc", status: "started", rpc_request_id: invocation.requestId,
|
|
171
|
+
call_id: callId, tool_name: toolName,
|
|
172
|
+
duration_ms: performance.now() - receivedAt, ...entry,
|
|
173
|
+
});
|
|
174
|
+
record({ stage: "received" });
|
|
175
|
+
try {
|
|
137
176
|
let payload;
|
|
138
177
|
try {
|
|
139
178
|
payload = JSON.parse(invocation.payload);
|
|
@@ -141,32 +180,47 @@ export class RealtimeSession {
|
|
|
141
180
|
catch (error) {
|
|
142
181
|
throw new RpcError(1500, "Tool invocation payload was not valid JSON", undefined, { cause: error });
|
|
143
182
|
}
|
|
144
|
-
if (typeof payload
|
|
183
|
+
if (typeof payload?.name !== "string") {
|
|
145
184
|
throw new RpcError(1500, "Tool invocation did not include a valid name");
|
|
146
185
|
}
|
|
147
|
-
|
|
186
|
+
toolName = payload.name;
|
|
187
|
+
callId = typeof payload.call_id === "string" ? payload.call_id : invocation.requestId;
|
|
188
|
+
const handler = this.toolHandlers.get(toolName);
|
|
148
189
|
if (!handler)
|
|
149
|
-
throw new RpcError(1500, `No browser handler is registered for ${
|
|
190
|
+
throw new RpcError(1500, `No browser handler is registered for ${toolName}`);
|
|
150
191
|
if (!payload.arguments || typeof payload.arguments !== "object" || Array.isArray(payload.arguments)) {
|
|
151
192
|
throw new RpcError(1500, "Tool invocation arguments must be an object");
|
|
152
193
|
}
|
|
153
|
-
const callId = typeof payload.call_id === "string" ? payload.call_id : invocation.requestId;
|
|
154
194
|
const arguments_ = payload.arguments;
|
|
155
|
-
this.notifyListeners(this.toolCallListeners, {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
195
|
+
this.notifyListeners(this.toolCallListeners, { name: toolName, callId, arguments: arguments_ });
|
|
196
|
+
record({ stage: "handler_started" });
|
|
197
|
+
this.flushDiagnostics("tool_rpc_received");
|
|
198
|
+
slowTimer = globalThis.setTimeout(() => {
|
|
199
|
+
record({ stage: "handler_started", reason: "handler_slow" });
|
|
200
|
+
this.flushDiagnostics("tool_rpc_slow");
|
|
201
|
+
}, 2_000);
|
|
202
|
+
let response;
|
|
160
203
|
try {
|
|
161
204
|
const result = await handler(arguments_, { callId });
|
|
162
|
-
|
|
205
|
+
response = JSON.stringify(result ?? null);
|
|
163
206
|
}
|
|
164
207
|
catch (error) {
|
|
165
208
|
const message = error instanceof Error ? error.message : "Browser tool handler failed";
|
|
166
209
|
throw new RpcError(1500, message, undefined, { cause: error });
|
|
167
210
|
}
|
|
168
|
-
|
|
169
|
-
|
|
211
|
+
// LiveKit sends the response after this callback returns. This is readiness,
|
|
212
|
+
// not proof of a network send or acknowledgement by the worker.
|
|
213
|
+
record({ stage: "response_ready", status: "succeeded" });
|
|
214
|
+
return response;
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
record({ status: "failed", rpc_error_code: error instanceof RpcError ? error.code : undefined });
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
globalThis.clearTimeout(slowTimer);
|
|
222
|
+
this.flushDiagnostics("tool_rpc_finished");
|
|
223
|
+
}
|
|
170
224
|
}
|
|
171
225
|
static async create(options) {
|
|
172
226
|
if ("connect" in options) {
|
|
@@ -334,6 +388,9 @@ export class RealtimeSession {
|
|
|
334
388
|
};
|
|
335
389
|
}
|
|
336
390
|
async connect(options = {}) {
|
|
391
|
+
if (this.startupConnectController || this.manualRejoinController) {
|
|
392
|
+
throw new Error("A session connection attempt is already in progress");
|
|
393
|
+
}
|
|
337
394
|
if (["connecting", "connected_not_ready", "connected", "reconnecting", "resuming"]
|
|
338
395
|
.includes(this.stateValue)) {
|
|
339
396
|
throw new Error(`Cannot connect while session is ${this.stateValue}`);
|
|
@@ -341,6 +398,17 @@ export class RealtimeSession {
|
|
|
341
398
|
this.cancelRecovery();
|
|
342
399
|
const controller = new AbortController();
|
|
343
400
|
this.startupConnectController = controller;
|
|
401
|
+
this.credentialRefreshGeneration += 1;
|
|
402
|
+
const resolved = { ...this.connectDefaults, ...options };
|
|
403
|
+
// Keep caller preferences separate from temporary startup-attempt budgets.
|
|
404
|
+
this.recoveryReadiness = {
|
|
405
|
+
readyTimeoutMs: resolved.readyTimeoutMs ?? 15_000,
|
|
406
|
+
waitForRemoteAudio: resolved.waitForRemoteAudio ?? true,
|
|
407
|
+
};
|
|
408
|
+
this.microphoneEnabled = resolved.audioTrack
|
|
409
|
+
? resolved.audioTrack.enabled !== false
|
|
410
|
+
: resolved.microphone ?? true;
|
|
411
|
+
this.setState("connecting");
|
|
344
412
|
try {
|
|
345
413
|
if (this.startupRetryOptions) {
|
|
346
414
|
await this.connectWithStartupRetry(options, controller);
|
|
@@ -397,6 +465,7 @@ export class RealtimeSession {
|
|
|
397
465
|
await this.registerStartupRecovery(remainingMs, attemptController.signal);
|
|
398
466
|
startupRegistered = true;
|
|
399
467
|
}
|
|
468
|
+
throwIfAborted(attemptController.signal);
|
|
400
469
|
if (index === 0) {
|
|
401
470
|
await this.connectWithMode({ ...options, readyTimeoutMs: attempt.readyTimeoutMs }, false, attemptController.signal, false);
|
|
402
471
|
}
|
|
@@ -439,7 +508,7 @@ export class RealtimeSession {
|
|
|
439
508
|
const controller = createLinkedAbortController(signal);
|
|
440
509
|
const requestTimeout = globalThis.setTimeout(() => {
|
|
441
510
|
controller.abort(new Error("Startup registration timed out"));
|
|
442
|
-
}, Math.max(1,
|
|
511
|
+
}, Math.max(1, reserveMs));
|
|
443
512
|
try {
|
|
444
513
|
// Leave room for registration latency and final readiness before the
|
|
445
514
|
// browser's existing overall deadline. Rejoins never renew this window.
|
|
@@ -452,7 +521,7 @@ export class RealtimeSession {
|
|
|
452
521
|
}), controller.signal);
|
|
453
522
|
let response = await send();
|
|
454
523
|
if (response.status === 401 && this.refreshCredentialsCallback) {
|
|
455
|
-
await
|
|
524
|
+
await this.refreshCredentials(controller.signal);
|
|
456
525
|
response = await send();
|
|
457
526
|
}
|
|
458
527
|
if (!response.ok)
|
|
@@ -477,6 +546,7 @@ export class RealtimeSession {
|
|
|
477
546
|
});
|
|
478
547
|
}
|
|
479
548
|
async connectWithMode(options, recovering, signal, markFailureTerminal = true) {
|
|
549
|
+
throwIfAborted(signal);
|
|
480
550
|
const blockedStates = recovering
|
|
481
551
|
? ["connecting", "connected_not_ready", "connected", "reconnecting"]
|
|
482
552
|
: ["connecting", "connected_not_ready", "connected", "reconnecting", "resuming"];
|
|
@@ -495,8 +565,6 @@ export class RealtimeSession {
|
|
|
495
565
|
const apiBase = normalizeApiBase(resolved.apiBase ?? this.apiBase);
|
|
496
566
|
this.validateAudioOptions(microphone, audioTrack);
|
|
497
567
|
this.useManagedMicrophone = microphone;
|
|
498
|
-
if (!recovering)
|
|
499
|
-
this.managedMicrophoneEnabled = microphone;
|
|
500
568
|
this.applicationAudioTrack = audioTrack;
|
|
501
569
|
if (!recovering) {
|
|
502
570
|
this.sessionEnded = false;
|
|
@@ -520,7 +588,7 @@ export class RealtimeSession {
|
|
|
520
588
|
await raceWithAbort(this.publishApplicationAudioTrack(audioTrack), signal);
|
|
521
589
|
}
|
|
522
590
|
else if (microphone) {
|
|
523
|
-
await raceWithAbort(this.room.localParticipant.setMicrophoneEnabled(this.
|
|
591
|
+
await raceWithAbort(this.room.localParticipant.setMicrophoneEnabled(this.microphoneEnabled), signal);
|
|
524
592
|
}
|
|
525
593
|
throwIfAborted(signal);
|
|
526
594
|
this.setState(recovering ? "resuming" : "connected_not_ready");
|
|
@@ -595,10 +663,13 @@ export class RealtimeSession {
|
|
|
595
663
|
});
|
|
596
664
|
}
|
|
597
665
|
async rejoin(apiBase = this.apiBase) {
|
|
666
|
+
if (this.startupConnectController || this.manualRejoinController) {
|
|
667
|
+
throw new Error("A session connection attempt is already in progress");
|
|
668
|
+
}
|
|
598
669
|
if (this.sessionEnded) {
|
|
599
670
|
throw this.terminalSessionError ?? new Error("Cannot rejoin a session that has ended");
|
|
600
671
|
}
|
|
601
|
-
if (["connecting", "reconnecting", "resuming"].includes(this.stateValue)) {
|
|
672
|
+
if (["connecting", "connected_not_ready", "reconnecting", "resuming"].includes(this.stateValue)) {
|
|
602
673
|
throw new Error(`Cannot rejoin while session is ${this.stateValue}`);
|
|
603
674
|
}
|
|
604
675
|
this.cancelRecovery();
|
|
@@ -633,13 +704,13 @@ export class RealtimeSession {
|
|
|
633
704
|
}
|
|
634
705
|
}
|
|
635
706
|
async performRejoin(apiBase, options = {}) {
|
|
636
|
-
const { signal, recovering = true, connect
|
|
707
|
+
const { signal, recovering = true, connect, markFailureTerminal = true, } = options;
|
|
637
708
|
this.contextGeneration += 1;
|
|
638
709
|
this.replacementContext.pause();
|
|
639
710
|
this.rejectPendingCommands(new Error("Restoring the session"));
|
|
640
711
|
let response = await this.fetchRoomToken(apiBase, signal);
|
|
641
712
|
if (response.status === 401 && this.refreshCredentialsCallback) {
|
|
642
|
-
await
|
|
713
|
+
await this.refreshCredentials(signal);
|
|
643
714
|
throwIfAborted(signal);
|
|
644
715
|
response = await this.fetchRoomToken(apiBase, signal);
|
|
645
716
|
}
|
|
@@ -655,6 +726,7 @@ export class RealtimeSession {
|
|
|
655
726
|
throw await raceWithAbort(httpError(response, "Could not renew room token"), signal);
|
|
656
727
|
}
|
|
657
728
|
const payload = (await raceWithAbort(response.json(), signal));
|
|
729
|
+
throwIfAborted(signal);
|
|
658
730
|
this.credentials = payload.connection;
|
|
659
731
|
if (this.room.state !== LiveKitConnectionState.Disconnected) {
|
|
660
732
|
this.disconnectMode = "rejoin";
|
|
@@ -673,16 +745,28 @@ export class RealtimeSession {
|
|
|
673
745
|
duration_ms: Date.now() - this.reconnectStartedAt,
|
|
674
746
|
};
|
|
675
747
|
}
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
748
|
+
// Startup retries resolve the original caller options with constructor defaults.
|
|
749
|
+
// A later rejoin restores the audio settings saved by the previous connection.
|
|
750
|
+
const connectionOptions = connect ?? {
|
|
751
|
+
...this.recoveryReadiness,
|
|
679
752
|
microphone: this.useManagedMicrophone,
|
|
680
753
|
...(this.applicationAudioTrack ? { audioTrack: this.applicationAudioTrack } : {}),
|
|
681
|
-
}
|
|
754
|
+
};
|
|
755
|
+
await this.connectWithMode({ ...connectionOptions, apiBase }, recovering, signal, markFailureTerminal);
|
|
682
756
|
}
|
|
683
757
|
async setMicrophoneEnabled(enabled) {
|
|
758
|
+
// Recovery must see the user's latest intent while the media operation waits.
|
|
759
|
+
this.microphoneEnabled = enabled;
|
|
760
|
+
if (this.applicationAudioTrack) {
|
|
761
|
+
// The room may not know this track yet while publication is pending.
|
|
762
|
+
const track = this.localApplicationAudioTrack;
|
|
763
|
+
if (enabled)
|
|
764
|
+
await track?.unmute();
|
|
765
|
+
else
|
|
766
|
+
await track?.mute();
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
684
769
|
await this.room.localParticipant.setMicrophoneEnabled(enabled);
|
|
685
|
-
this.managedMicrophoneEnabled = enabled;
|
|
686
770
|
}
|
|
687
771
|
async sendCommand(type, payload, timeoutMs = 10_000) {
|
|
688
772
|
const message = createClientMessage(this.connectionId, ++this.clientSeq, type, payload);
|
|
@@ -709,7 +793,13 @@ export class RealtimeSession {
|
|
|
709
793
|
context_key: update.key,
|
|
710
794
|
payload_bytes: payloadBytes,
|
|
711
795
|
});
|
|
712
|
-
|
|
796
|
+
this.activeContextTransfers += 1;
|
|
797
|
+
this.activeContextBytes += payloadBytes;
|
|
798
|
+
const uploading = ("image" in update ? this.uploadImageContext(update) : this.uploadTextContext(update))
|
|
799
|
+
.finally(() => {
|
|
800
|
+
this.activeContextTransfers -= 1;
|
|
801
|
+
this.activeContextBytes -= payloadBytes;
|
|
802
|
+
});
|
|
713
803
|
const transferSettled = uploading.then(() => { }, () => { });
|
|
714
804
|
let transferTimeout;
|
|
715
805
|
const transferExpired = new Promise((_resolve, reject) => {
|
|
@@ -866,6 +956,7 @@ export class RealtimeSession {
|
|
|
866
956
|
this.failRecovery(new Error("Session disconnected during recovery"));
|
|
867
957
|
this.cancelRecovery();
|
|
868
958
|
this.contextGeneration += 1;
|
|
959
|
+
this.localApplicationAudioTrack = null;
|
|
869
960
|
this.replacementContext.clear(new Error("Session disconnected"));
|
|
870
961
|
if (this.stateValue === "disconnected")
|
|
871
962
|
return;
|
|
@@ -881,6 +972,7 @@ export class RealtimeSession {
|
|
|
881
972
|
}
|
|
882
973
|
}
|
|
883
974
|
async end(options = {}) {
|
|
975
|
+
this.credentialRefreshGeneration += 1;
|
|
884
976
|
this.abortStartupConnect(new Error("Session ended"));
|
|
885
977
|
this.startupConnectController = null;
|
|
886
978
|
this.failRecovery(new Error("Session ended during recovery"));
|
|
@@ -900,7 +992,7 @@ export class RealtimeSession {
|
|
|
900
992
|
credentials: request.credentials ?? this.lifecycleCredentials,
|
|
901
993
|
});
|
|
902
994
|
if (response.status === 401 && usesScopedSessionEndpoint && this.refreshCredentialsCallback) {
|
|
903
|
-
await this.refreshCredentials();
|
|
995
|
+
await this.refreshCredentials(request.signal ?? undefined);
|
|
904
996
|
headers.set("Authorization", `Bearer ${this.accessToken}`);
|
|
905
997
|
response = await this.fetcher(endpoint, {
|
|
906
998
|
...request,
|
|
@@ -928,9 +1020,13 @@ export class RealtimeSession {
|
|
|
928
1020
|
return;
|
|
929
1021
|
this.stateValue = state;
|
|
930
1022
|
if (state === "connecting" || state === "resuming") {
|
|
1023
|
+
this.startDiagnosticLoopMonitor();
|
|
931
1024
|
globalThis.document?.addEventListener?.("visibilitychange", this.onDiagnosticVisibilityChange);
|
|
932
1025
|
}
|
|
933
1026
|
if (state === "disconnected" || state === "failed") {
|
|
1027
|
+
globalThis.clearInterval(this.diagnosticLoopTimer);
|
|
1028
|
+
this.diagnosticLoopTimer = undefined;
|
|
1029
|
+
this.diagnosticLoopDelayMs = undefined;
|
|
934
1030
|
globalThis.document?.removeEventListener?.("visibilitychange", this.onDiagnosticVisibilityChange);
|
|
935
1031
|
this.flushDiagnostics("session_finished");
|
|
936
1032
|
const error = new Error(`Session ${state}`);
|
|
@@ -1086,9 +1182,9 @@ export class RealtimeSession {
|
|
|
1086
1182
|
this.setState(this.hasConnectedValue ? "resuming" : "connected_not_ready");
|
|
1087
1183
|
try {
|
|
1088
1184
|
await this.sendHello();
|
|
1089
|
-
const deadline = Date.now() +
|
|
1185
|
+
const deadline = Date.now() + this.recoveryReadiness.readyTimeoutMs;
|
|
1090
1186
|
await this.waitForAgentReady(Math.max(deadline - Date.now(), 0));
|
|
1091
|
-
if (this.
|
|
1187
|
+
if (this.recoveryReadiness.waitForRemoteAudio)
|
|
1092
1188
|
await this.waitForRemoteAudioTrack(deadline);
|
|
1093
1189
|
if (this.sessionEnded || generation !== this.contextGeneration)
|
|
1094
1190
|
return;
|
|
@@ -1371,10 +1467,18 @@ export class RealtimeSession {
|
|
|
1371
1467
|
signal?.removeEventListener("abort", cancel);
|
|
1372
1468
|
}
|
|
1373
1469
|
}
|
|
1374
|
-
async refreshCredentials() {
|
|
1470
|
+
async refreshCredentials(signal) {
|
|
1375
1471
|
if (!this.refreshCredentialsCallback)
|
|
1376
1472
|
return;
|
|
1377
|
-
|
|
1473
|
+
throwIfAborted(signal);
|
|
1474
|
+
const connectionGeneration = this.contextGeneration;
|
|
1475
|
+
const refreshGeneration = ++this.credentialRefreshGeneration;
|
|
1476
|
+
const refreshed = await raceWithAbort(this.refreshCredentialsCallback(), signal);
|
|
1477
|
+
throwIfAborted(signal);
|
|
1478
|
+
if (connectionGeneration !== this.contextGeneration
|
|
1479
|
+
|| refreshGeneration !== this.credentialRefreshGeneration) {
|
|
1480
|
+
throw new Error("Credential refresh was superseded");
|
|
1481
|
+
}
|
|
1378
1482
|
validateSessionCredentials(refreshed);
|
|
1379
1483
|
if (refreshed.id !== this.id) {
|
|
1380
1484
|
throw new Error("Refreshed credentials belong to a different session");
|
|
@@ -1469,15 +1573,26 @@ export class RealtimeSession {
|
|
|
1469
1573
|
}
|
|
1470
1574
|
}
|
|
1471
1575
|
async publishApplicationAudioTrack(audioTrack) {
|
|
1472
|
-
const
|
|
1576
|
+
const clonedTrack = audioTrack.clone();
|
|
1577
|
+
let publishedTrack = null;
|
|
1473
1578
|
try {
|
|
1579
|
+
publishedTrack = new LocalAudioTrack(clonedTrack, undefined, true);
|
|
1580
|
+
this.localApplicationAudioTrack = publishedTrack;
|
|
1581
|
+
if (!this.microphoneEnabled) {
|
|
1582
|
+
// LiveKit can re-enable a raw track when wrapping it. Mute the wrapper
|
|
1583
|
+
// before publication so recovery cannot briefly transmit muted input.
|
|
1584
|
+
await publishedTrack.mute();
|
|
1585
|
+
}
|
|
1474
1586
|
await this.room.localParticipant.publishTrack(publishedTrack, {
|
|
1475
1587
|
source: Track.Source.Microphone,
|
|
1476
1588
|
name: "voxevolv-application-audio",
|
|
1477
1589
|
});
|
|
1478
1590
|
}
|
|
1479
1591
|
catch (error) {
|
|
1480
|
-
publishedTrack
|
|
1592
|
+
if (this.localApplicationAudioTrack === publishedTrack) {
|
|
1593
|
+
this.localApplicationAudioTrack = null;
|
|
1594
|
+
}
|
|
1595
|
+
clonedTrack.stop();
|
|
1481
1596
|
throw error;
|
|
1482
1597
|
}
|
|
1483
1598
|
}
|
|
@@ -1685,12 +1800,32 @@ export class RealtimeSession {
|
|
|
1685
1800
|
async publishCommand(message) {
|
|
1686
1801
|
await this.room.localParticipant.publishData(new TextEncoder().encode(JSON.stringify(message)), { reliable: true, topic: "voxevolv.commands" });
|
|
1687
1802
|
}
|
|
1803
|
+
startDiagnosticLoopMonitor() {
|
|
1804
|
+
if (this.diagnosticLoopTimer !== undefined)
|
|
1805
|
+
return;
|
|
1806
|
+
this.diagnosticLoopExpectedAt = performance.now() + 1_000;
|
|
1807
|
+
this.diagnosticLoopTimer = globalThis.setInterval(() => {
|
|
1808
|
+
const now = performance.now();
|
|
1809
|
+
this.diagnosticLoopDelayMs = Math.max(0, now - this.diagnosticLoopExpectedAt);
|
|
1810
|
+
this.diagnosticLoopExpectedAt = now + 1_000;
|
|
1811
|
+
}, 1_000);
|
|
1812
|
+
this.diagnosticLoopTimer.unref?.();
|
|
1813
|
+
}
|
|
1688
1814
|
recordDiagnostic(entry) {
|
|
1689
|
-
|
|
1815
|
+
const snapshot = {
|
|
1690
1816
|
...entry,
|
|
1691
1817
|
connection_state: this.stateValue,
|
|
1692
1818
|
buffered_bytes: this.reliableDataChannelBufferedBytes(),
|
|
1693
|
-
}
|
|
1819
|
+
};
|
|
1820
|
+
if (entry.operation === "tool_rpc") {
|
|
1821
|
+
snapshot.page_visibility = globalThis.document?.visibilityState;
|
|
1822
|
+
snapshot.active_context_transfers = this.activeContextTransfers;
|
|
1823
|
+
snapshot.active_context_bytes = this.activeContextBytes;
|
|
1824
|
+
if (this.diagnosticLoopTimer !== undefined) {
|
|
1825
|
+
snapshot.event_loop_delay_ms = Math.max(this.diagnosticLoopDelayMs ?? 0, performance.now() - this.diagnosticLoopExpectedAt);
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
this.diagnostics.record(snapshot);
|
|
1694
1829
|
}
|
|
1695
1830
|
reliableDataChannelBufferedBytes() {
|
|
1696
1831
|
try {
|
|
@@ -1911,7 +2046,7 @@ export class RealtimeSession {
|
|
|
1911
2046
|
signal,
|
|
1912
2047
|
}), signal);
|
|
1913
2048
|
if (response.status === 401 && this.refreshCredentialsCallback) {
|
|
1914
|
-
await
|
|
2049
|
+
await this.refreshCredentials(signal);
|
|
1915
2050
|
response = await raceWithAbort(this.fetcher(url, {
|
|
1916
2051
|
headers: { Authorization: `Bearer ${this.accessToken}` },
|
|
1917
2052
|
credentials: this.lifecycleCredentials,
|