@nuancelabs/js-sdk 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/index.d.ts +2 -8
  2. package/index.js +163 -18
  3. package/package.json +1 -1
package/index.d.ts CHANGED
@@ -216,8 +216,8 @@ export declare type ConversationEvent =
216
216
  /**
217
217
  * How long the live session has left, when it is capped.
218
218
  *
219
- * Emitted on each heartbeat, so it steps rather than ticks; a caller showing a
220
- * countdown should interpolate between them.
219
+ * Emitted when the time left is learned or corrected, so it steps rather
220
+ * than ticks; a caller showing a countdown should count down from the latest.
221
221
  */
222
222
  | {
223
223
  type: "time-remaining";
@@ -344,9 +344,6 @@ export declare interface CreateNuanceConversationOptions {
344
344
  }
345
345
 
346
346
 
347
-
348
-
349
-
350
347
  /**
351
348
  * Raised by {@link Conversation.connect} when the server refuses a new session
352
349
  * because the account has not completed onboarding or accepted the current
@@ -406,7 +403,6 @@ export declare type MediaErrorReason = "permission-denied" | "device-not-found"
406
403
  export declare type NetworkQuality = "good" | "poor" | "lost";
407
404
 
408
405
 
409
-
410
406
  /**
411
407
  * Raised by {@link Conversation.connect} when the server refuses a new session
412
408
  * because the runtime recently ended too many of this account's sessions for
@@ -428,11 +424,9 @@ export declare type SessionEndReason = "time-limit" | "ended" | "policy-violatio
428
424
 
429
425
 
430
426
 
431
-
432
427
  /** When the avatar starts speaking: see {@link CreateNuanceConversationOptions.startOn}. */
433
428
  export declare type StartOn = "first-frame" | "full-quality";
434
429
 
435
-
436
430
  /**
437
431
  * The local user participant in a conversation.
438
432
  */
package/index.js CHANGED
@@ -4,7 +4,7 @@ function isRecord(value) {
4
4
  }
5
5
 
6
6
  // src/core/sdk-version.ts
7
- var SDK_HEADER = `js/${"0.2.2"}`;
7
+ var SDK_HEADER = `js/${"0.4.0"}`;
8
8
 
9
9
  // src/utils.ts
10
10
  function normalizeHost(host) {
@@ -56,6 +56,9 @@ class NuanceApiClient {
56
56
  async heartbeatConversation(conversationId) {
57
57
  return await this.request(`/conversation/${conversationId}/heartbeat`, { method: "POST", token: this.tokens.get(conversationId) });
58
58
  }
59
+ async conversationEvents(conversationId, after, signal) {
60
+ return await this.request(`/conversation/${conversationId}/events?after=${after}`, { token: this.tokens.get(conversationId), signal });
61
+ }
59
62
  async abandonConversation(conversationId) {
60
63
  return await this.request(`/conversation/${conversationId}/abandon`, { method: "POST", token: this.tokens.get(conversationId) });
61
64
  }
@@ -71,7 +74,8 @@ class NuanceApiClient {
71
74
  const response = await fetch(`${this.host}${path}`, {
72
75
  method: options?.method ?? "GET",
73
76
  headers,
74
- body: options?.body !== undefined ? JSON.stringify(options.body) : undefined
77
+ body: options?.body !== undefined ? JSON.stringify(options.body) : undefined,
78
+ signal: options?.signal
75
79
  });
76
80
  if (!response.ok) {
77
81
  let detail = response.statusText;
@@ -2137,7 +2141,8 @@ class LiveKitConversation {
2137
2141
  var DEFAULT_HOST = "https://api.nuancelabs.ai";
2138
2142
  var QUEUE_HEARTBEAT_INTERVAL_MS = 5000;
2139
2143
  var HEARTBEAT_INTERVAL_MS = 5000;
2140
- var ADMISSION_FAILED_BEATS = 6;
2144
+ var EVENTS_RETRY_MS = 5000;
2145
+ var ADMISSION_FAILED_REQUESTS = 6;
2141
2146
  var BUSY_RETRY_LIMIT_MS = 60000;
2142
2147
  var BUSY_RETRY_DEFAULT_MS = 3000;
2143
2148
  var LOST_WINDOW_MS = 30000;
@@ -2204,6 +2209,7 @@ class NuanceConversation {
2204
2209
  delegate = null;
2205
2210
  delegateUnsubscribe = null;
2206
2211
  stopBeats = null;
2212
+ stopEvents = null;
2207
2213
  admitted = null;
2208
2214
  failedBeats = 0;
2209
2215
  resilient = false;
@@ -2292,10 +2298,8 @@ class NuanceConversation {
2292
2298
  this._id = conversation.id;
2293
2299
  this.held = conversation.id;
2294
2300
  this.emit({ type: "session-created", id: conversation.id });
2295
- this.admitted = Promise.withResolvers();
2296
- this.startHeartbeat(conversation.id, QUEUE_HEARTBEAT_INTERVAL_MS);
2297
- await this.admitted.promise;
2298
- this.delegate = await this.createDelegate(conversation, mediaDevices, attempt);
2301
+ const grant = await this.waitForSlot(conversation.id, attempt);
2302
+ this.delegate = await this.createDelegate(conversation, grant, mediaDevices, attempt);
2299
2303
  this.delegateUnsubscribe = this.delegate.onEvent((event) => {
2300
2304
  if (event.type === "state-changed") {
2301
2305
  this.setState(event.state);
@@ -2318,7 +2322,9 @@ class NuanceConversation {
2318
2322
  this.emit(event);
2319
2323
  });
2320
2324
  await this.delegate.connect();
2321
- this.startHeartbeat(conversation.id, HEARTBEAT_INTERVAL_MS);
2325
+ if (grant === null) {
2326
+ this.startHeartbeat(conversation.id, HEARTBEAT_INTERVAL_MS);
2327
+ }
2322
2328
  } catch (error) {
2323
2329
  if (attempt !== this.attempt) {
2324
2330
  throw error;
@@ -2332,6 +2338,8 @@ class NuanceConversation {
2332
2338
  this.attempt += 1;
2333
2339
  this.clearLimit();
2334
2340
  this.stopHeartbeat();
2341
+ this.stopEvents?.();
2342
+ this.stopEvents = null;
2335
2343
  this.admitted?.reject(new Error("Disconnected while waiting for a slot"));
2336
2344
  this.admitted = null;
2337
2345
  this.delegateUnsubscribe?.();
@@ -2390,19 +2398,146 @@ class NuanceConversation {
2390
2398
  }
2391
2399
  });
2392
2400
  }
2393
- async createDelegate(conversation, mediaDevices, attempt) {
2401
+ async waitForSlot(conversationId, attempt) {
2402
+ const admission = await this.followEvents(conversationId, attempt);
2403
+ if (admission !== null) {
2404
+ this.followLive(conversationId, admission.after, attempt);
2405
+ return admission.grant;
2406
+ }
2407
+ this.admitted = Promise.withResolvers();
2408
+ this.startHeartbeat(conversationId, QUEUE_HEARTBEAT_INTERVAL_MS);
2409
+ await this.admitted.promise;
2410
+ return null;
2411
+ }
2412
+ async followEvents(conversationId, attempt) {
2413
+ const abort = new AbortController;
2414
+ const stop = () => abort.abort();
2415
+ this.stopEvents = stop;
2416
+ let after = 0;
2417
+ let answered = false;
2418
+ let failures = 0;
2419
+ try {
2420
+ for (;; ) {
2421
+ let events;
2422
+ try {
2423
+ ({ events } = await this.api.conversationEvents(conversationId, after, abort.signal));
2424
+ } catch (error) {
2425
+ if (attempt !== this.attempt)
2426
+ throw new DisconnectedError;
2427
+ if (!answered && error instanceof ApiError && error.status === 404) {
2428
+ return null;
2429
+ }
2430
+ failures += 1;
2431
+ if (failures >= ADMISSION_FAILED_REQUESTS) {
2432
+ throw new Error("Lost contact with the server while waiting", {
2433
+ cause: error
2434
+ });
2435
+ }
2436
+ await this.pause(EVENTS_RETRY_MS, abort.signal);
2437
+ if (attempt !== this.attempt)
2438
+ throw new DisconnectedError;
2439
+ continue;
2440
+ }
2441
+ if (attempt !== this.attempt)
2442
+ throw new DisconnectedError;
2443
+ answered = true;
2444
+ failures = 0;
2445
+ for (const event of events) {
2446
+ if (event.type === "admitted") {
2447
+ const grant = {
2448
+ server_url: event.livekit_url,
2449
+ participant_token: event.token
2450
+ };
2451
+ return { grant, after: event.id };
2452
+ }
2453
+ after = Math.max(after, event.id);
2454
+ if (event.type === "ended") {
2455
+ throw new TerminalStatusError(event.reason);
2456
+ }
2457
+ if (event.type === "queued" && event.position != null) {
2458
+ this.reportPlace(event.position, event.estimated_wait_seconds ?? null, null);
2459
+ }
2460
+ }
2461
+ }
2462
+ } finally {
2463
+ if (this.stopEvents === stop)
2464
+ this.stopEvents = null;
2465
+ }
2466
+ }
2467
+ async followLive(conversationId, after, attempt) {
2468
+ const abort = new AbortController;
2469
+ const stop = () => abort.abort();
2470
+ this.stopEvents = stop;
2471
+ let failures = 0;
2472
+ try {
2473
+ while (attempt === this.attempt && !this.ended) {
2474
+ let events;
2475
+ try {
2476
+ ({ events } = await this.api.conversationEvents(conversationId, after, abort.signal));
2477
+ } catch {
2478
+ if (attempt !== this.attempt)
2479
+ return;
2480
+ failures += 1;
2481
+ await this.pause(EVENTS_RETRY_MS, abort.signal);
2482
+ continue;
2483
+ }
2484
+ if (attempt !== this.attempt)
2485
+ return;
2486
+ const receivedAt = Date.now();
2487
+ this.lostContact = failures > 0;
2488
+ failures = 0;
2489
+ for (const event of events) {
2490
+ after = Math.max(after, event.id);
2491
+ if (event.type === "started" && event.seconds_remaining != null) {
2492
+ this.capped = true;
2493
+ this.emit({
2494
+ type: "time-remaining",
2495
+ secondsRemaining: event.seconds_remaining
2496
+ });
2497
+ this.armLimit(event.seconds_remaining, receivedAt);
2498
+ }
2499
+ if (event.type === "ended") {
2500
+ this.endSession(this.endReason(event.reason));
2501
+ return;
2502
+ }
2503
+ }
2504
+ }
2505
+ } finally {
2506
+ if (this.stopEvents === stop)
2507
+ this.stopEvents = null;
2508
+ }
2509
+ }
2510
+ endReason(reason) {
2511
+ if (reason === "time_limit" || this.atLimit())
2512
+ return "time-limit";
2513
+ if (reason === "policy_violation")
2514
+ return "policy-violation";
2515
+ if (this.cutOff())
2516
+ return "connection-lost";
2517
+ return "ended";
2518
+ }
2519
+ pause(ms, signal) {
2520
+ return new Promise((resolve) => {
2521
+ let stopTicker = () => {};
2522
+ const done = () => {
2523
+ stopTicker();
2524
+ signal.removeEventListener("abort", done);
2525
+ resolve();
2526
+ };
2527
+ stopTicker = this.ticker(done, ms);
2528
+ signal.addEventListener("abort", done);
2529
+ });
2530
+ }
2531
+ async createDelegate(conversation, grant, mediaDevices, attempt) {
2394
2532
  if (conversation.provider !== "livekit") {
2395
2533
  throw new Error(`Unsupported provider ${conversation.provider}`);
2396
2534
  }
2397
- const response = await this.api.connectConversation(conversation.id);
2535
+ const connection = grant ?? await this.connectRoom(conversation.id);
2398
2536
  if (attempt !== this.attempt) {
2399
2537
  throw new DisconnectedError;
2400
2538
  }
2401
- if (response.connection.kind !== "livekit") {
2402
- throw new Error(`Expected LiveKit connection, received ${response.connection.kind}`);
2403
- }
2404
2539
  this.resilient = typeof this.resilientReconnect === "function" ? this.resilientReconnect() : this.resilientReconnect;
2405
- return this.provider(response.connection, {
2540
+ return this.provider(connection, {
2406
2541
  conversationId: conversation.id,
2407
2542
  mediaDevices,
2408
2543
  cameraEnabled: !this.sessionOverrides.disable_camera && mediaDevices.cameraEnabled !== false,
@@ -2411,6 +2546,13 @@ class NuanceConversation {
2411
2546
  resilientReconnect: this.resilient
2412
2547
  });
2413
2548
  }
2549
+ async connectRoom(conversationId) {
2550
+ const response = await this.api.connectConversation(conversationId);
2551
+ if (response.connection.kind !== "livekit") {
2552
+ throw new Error(`Expected LiveKit connection, received ${response.connection.kind}`);
2553
+ }
2554
+ return response.connection;
2555
+ }
2414
2556
  async createWhenNotBusy(request, attempt) {
2415
2557
  const giveUpAt = Date.now() + BUSY_RETRY_LIMIT_MS;
2416
2558
  for (;; ) {
@@ -2458,7 +2600,7 @@ class NuanceConversation {
2458
2600
  return;
2459
2601
  if (!(error instanceof TerminalStatusError)) {
2460
2602
  this.failedBeats += 1;
2461
- if (this.admitted && this.failedBeats >= ADMISSION_FAILED_BEATS) {
2603
+ if (this.admitted && this.failedBeats >= ADMISSION_FAILED_REQUESTS) {
2462
2604
  this.admitted.reject(new Error("Lost contact with the server while waiting", {
2463
2605
  cause: error
2464
2606
  }));
@@ -2497,14 +2639,17 @@ class NuanceConversation {
2497
2639
  if (state.status !== "queued" || state.position == null) {
2498
2640
  return;
2499
2641
  }
2642
+ this.reportPlace(state.position, state.estimated_wait_seconds ?? null, state.pool_slots ?? null);
2643
+ }
2644
+ reportPlace(position, estimate, poolSlots) {
2500
2645
  if (this._state !== "queued") {
2501
2646
  this.setState("queued");
2502
2647
  }
2503
2648
  this.emit({
2504
2649
  type: "queue-changed",
2505
- position: state.position,
2506
- estimatedWaitSeconds: this.smoothEstimate(state.position, state.estimated_wait_seconds ?? null),
2507
- poolSlots: state.pool_slots ?? null
2650
+ position,
2651
+ estimatedWaitSeconds: this.smoothEstimate(position, estimate),
2652
+ poolSlots
2508
2653
  });
2509
2654
  }
2510
2655
  smoothEstimate(position, estimate) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuancelabs/js-sdk",
3
- "version": "0.2.2",
3
+ "version": "0.4.0",
4
4
  "description": "JavaScript SDK for Nuance Labs conversation video AI avatar",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {