@nuancelabs/js-sdk 0.2.1 → 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 +219 -22
  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.1"}`;
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;
@@ -1167,6 +1171,15 @@ function microphonePublishOptions(resilient) {
1167
1171
  return;
1168
1172
  return { dtx: false, audioPreset: AudioPresets.speech };
1169
1173
  }
1174
+ var SCREEN_CAPTURE = {
1175
+ audio: false,
1176
+ resolution: { width: 1920, height: 1080, frameRate: 2 },
1177
+ contentHint: "detail"
1178
+ };
1179
+ var SCREEN_PUBLISH = {
1180
+ screenShareEncoding: { maxBitrate: 1500000, maxFramerate: 2 },
1181
+ simulcast: false
1182
+ };
1170
1183
 
1171
1184
  class LiveKitUserParticipant {
1172
1185
  room;
@@ -1174,6 +1187,8 @@ class LiveKitUserParticipant {
1174
1187
  onSpeakingChanged;
1175
1188
  onMicrophoneChanged;
1176
1189
  onCameraChanged;
1190
+ onScreenShareChanged;
1191
+ screenTrack;
1177
1192
  inputs = {
1178
1193
  audioinput: { changing: false, unavailable: false },
1179
1194
  videoinput: { changing: false, unavailable: false }
@@ -1188,7 +1203,7 @@ class LiveKitUserParticipant {
1188
1203
  lastAudioSource = null;
1189
1204
  _isSpeaking = false;
1190
1205
  speakingHandler = null;
1191
- constructor(room, onTracksChanged, onSpeakingChanged, onMicrophoneChanged, onCameraChanged = () => {}, initialCameraFacing, resilient = false) {
1206
+ constructor(room, onTracksChanged, onSpeakingChanged, onMicrophoneChanged, onCameraChanged = () => {}, initialCameraFacing, resilient = false, onScreenShareChanged = () => {}) {
1192
1207
  this.room = room;
1193
1208
  this.onTracksChanged = onTracksChanged;
1194
1209
  this.onSpeakingChanged = onSpeakingChanged;
@@ -1196,7 +1211,9 @@ class LiveKitUserParticipant {
1196
1211
  this.onCameraChanged = onCameraChanged;
1197
1212
  this.initialCameraFacing = initialCameraFacing;
1198
1213
  this.microphoneOptions = microphonePublishOptions(resilient);
1214
+ this.onScreenShareChanged = onScreenShareChanged;
1199
1215
  this.room.on(RoomEvent2.ActiveDeviceChanged, this.handleDeviceChanged);
1216
+ this.room.localParticipant.on(ParticipantEvent2.LocalTrackUnpublished, this.handleTrackUnpublished);
1200
1217
  }
1201
1218
  updateTracks() {
1202
1219
  let changed = false;
@@ -1357,9 +1374,14 @@ class LiveKitUserParticipant {
1357
1374
  this.notifyInput(kind);
1358
1375
  }
1359
1376
  };
1377
+ handleTrackUnpublished = (publication) => {
1378
+ if (!this.disposed && publication.source === Track5.Source.ScreenShare)
1379
+ this.onScreenShareChanged();
1380
+ };
1360
1381
  cleanup() {
1361
1382
  this.disposed = true;
1362
1383
  this.room.off(RoomEvent2.ActiveDeviceChanged, this.handleDeviceChanged);
1384
+ this.room.localParticipant.off(ParticipantEvent2.LocalTrackUnpublished, this.handleTrackUnpublished);
1363
1385
  if (this.inputs.audioinput.changing)
1364
1386
  this.inputTrack("audioinput")?.stop();
1365
1387
  if (this.inputs.videoinput.changing)
@@ -1437,10 +1459,44 @@ class LiveKitUserParticipant {
1437
1459
  get isVideoEnabled() {
1438
1460
  return this.room.localParticipant.isCameraEnabled;
1439
1461
  }
1462
+ async startScreenShare() {
1463
+ await this.setScreenShare(true);
1464
+ }
1465
+ async stopScreenShare() {
1466
+ const track = this.screenTrack;
1467
+ this.screenTrack = undefined;
1468
+ try {
1469
+ if (!this.disposed)
1470
+ await this.setScreenShare(false);
1471
+ } finally {
1472
+ track?.stop();
1473
+ }
1474
+ }
1475
+ get isScreenSharing() {
1476
+ return this.room.localParticipant.isScreenShareEnabled;
1477
+ }
1478
+ async setScreenShare(enabled) {
1479
+ if (this.disposed)
1480
+ throw new Error("Participant is disconnected");
1481
+ try {
1482
+ const publication = await this.room.localParticipant.setScreenShareEnabled(enabled, SCREEN_CAPTURE, SCREEN_PUBLISH);
1483
+ if (this.disposed)
1484
+ publication?.track?.stop();
1485
+ else if (enabled)
1486
+ this.screenTrack = publication?.track;
1487
+ } catch (error) {
1488
+ throw new MediaAccessError(error);
1489
+ } finally {
1490
+ if (!this.disposed)
1491
+ this.onScreenShareChanged();
1492
+ }
1493
+ }
1440
1494
  releaseDevices() {
1441
1495
  for (const pub of this.room.localParticipant.trackPublications.values()) {
1442
1496
  pub.track?.stop();
1443
1497
  }
1498
+ this.screenTrack?.stop();
1499
+ this.screenTrack = undefined;
1444
1500
  }
1445
1501
  startSpeakingDetection() {
1446
1502
  this.stopSpeakingDetection();
@@ -1601,7 +1657,7 @@ class LiveKitConversation {
1601
1657
  type: "speaking-changed",
1602
1658
  participant: "user",
1603
1659
  isSpeaking
1604
- }), () => this.emit({ type: "microphone-changed" }), () => this.emit({ type: "camera-changed" }), this.cameraFacing, this.resilient);
1660
+ }), () => this.emit({ type: "microphone-changed" }), () => this.emit({ type: "camera-changed" }), this.cameraFacing, this.resilient, () => this.emit({ type: "screen-share-changed" }));
1605
1661
  this._user.startSpeakingDetection();
1606
1662
  try {
1607
1663
  await this._user.unmuteAudio();
@@ -1677,7 +1733,7 @@ class LiveKitConversation {
1677
1733
  }
1678
1734
  onChange(handler) {
1679
1735
  return this.onEvent((event) => {
1680
- if (event.type !== "data-event")
1736
+ if (event.type !== "data-event" && event.type !== "screen-share-changed")
1681
1737
  handler(event);
1682
1738
  });
1683
1739
  }
@@ -2085,7 +2141,8 @@ class LiveKitConversation {
2085
2141
  var DEFAULT_HOST = "https://api.nuancelabs.ai";
2086
2142
  var QUEUE_HEARTBEAT_INTERVAL_MS = 5000;
2087
2143
  var HEARTBEAT_INTERVAL_MS = 5000;
2088
- var ADMISSION_FAILED_BEATS = 6;
2144
+ var EVENTS_RETRY_MS = 5000;
2145
+ var ADMISSION_FAILED_REQUESTS = 6;
2089
2146
  var BUSY_RETRY_LIMIT_MS = 60000;
2090
2147
  var BUSY_RETRY_DEFAULT_MS = 3000;
2091
2148
  var LOST_WINDOW_MS = 30000;
@@ -2152,6 +2209,7 @@ class NuanceConversation {
2152
2209
  delegate = null;
2153
2210
  delegateUnsubscribe = null;
2154
2211
  stopBeats = null;
2212
+ stopEvents = null;
2155
2213
  admitted = null;
2156
2214
  failedBeats = 0;
2157
2215
  resilient = false;
@@ -2240,10 +2298,8 @@ class NuanceConversation {
2240
2298
  this._id = conversation.id;
2241
2299
  this.held = conversation.id;
2242
2300
  this.emit({ type: "session-created", id: conversation.id });
2243
- this.admitted = Promise.withResolvers();
2244
- this.startHeartbeat(conversation.id, QUEUE_HEARTBEAT_INTERVAL_MS);
2245
- await this.admitted.promise;
2246
- 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);
2247
2303
  this.delegateUnsubscribe = this.delegate.onEvent((event) => {
2248
2304
  if (event.type === "state-changed") {
2249
2305
  this.setState(event.state);
@@ -2266,7 +2322,9 @@ class NuanceConversation {
2266
2322
  this.emit(event);
2267
2323
  });
2268
2324
  await this.delegate.connect();
2269
- this.startHeartbeat(conversation.id, HEARTBEAT_INTERVAL_MS);
2325
+ if (grant === null) {
2326
+ this.startHeartbeat(conversation.id, HEARTBEAT_INTERVAL_MS);
2327
+ }
2270
2328
  } catch (error) {
2271
2329
  if (attempt !== this.attempt) {
2272
2330
  throw error;
@@ -2280,6 +2338,8 @@ class NuanceConversation {
2280
2338
  this.attempt += 1;
2281
2339
  this.clearLimit();
2282
2340
  this.stopHeartbeat();
2341
+ this.stopEvents?.();
2342
+ this.stopEvents = null;
2283
2343
  this.admitted?.reject(new Error("Disconnected while waiting for a slot"));
2284
2344
  this.admitted = null;
2285
2345
  this.delegateUnsubscribe?.();
@@ -2321,7 +2381,7 @@ class NuanceConversation {
2321
2381
  }
2322
2382
  onChange(handler) {
2323
2383
  return this.onEvent((event) => {
2324
- if (event.type !== "data-event")
2384
+ if (event.type !== "data-event" && event.type !== "screen-share-changed")
2325
2385
  handler(event);
2326
2386
  });
2327
2387
  }
@@ -2338,19 +2398,146 @@ class NuanceConversation {
2338
2398
  }
2339
2399
  });
2340
2400
  }
2341
- 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) {
2342
2532
  if (conversation.provider !== "livekit") {
2343
2533
  throw new Error(`Unsupported provider ${conversation.provider}`);
2344
2534
  }
2345
- const response = await this.api.connectConversation(conversation.id);
2535
+ const connection = grant ?? await this.connectRoom(conversation.id);
2346
2536
  if (attempt !== this.attempt) {
2347
2537
  throw new DisconnectedError;
2348
2538
  }
2349
- if (response.connection.kind !== "livekit") {
2350
- throw new Error(`Expected LiveKit connection, received ${response.connection.kind}`);
2351
- }
2352
2539
  this.resilient = typeof this.resilientReconnect === "function" ? this.resilientReconnect() : this.resilientReconnect;
2353
- return this.provider(response.connection, {
2540
+ return this.provider(connection, {
2354
2541
  conversationId: conversation.id,
2355
2542
  mediaDevices,
2356
2543
  cameraEnabled: !this.sessionOverrides.disable_camera && mediaDevices.cameraEnabled !== false,
@@ -2359,6 +2546,13 @@ class NuanceConversation {
2359
2546
  resilientReconnect: this.resilient
2360
2547
  });
2361
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
+ }
2362
2556
  async createWhenNotBusy(request, attempt) {
2363
2557
  const giveUpAt = Date.now() + BUSY_RETRY_LIMIT_MS;
2364
2558
  for (;; ) {
@@ -2406,7 +2600,7 @@ class NuanceConversation {
2406
2600
  return;
2407
2601
  if (!(error instanceof TerminalStatusError)) {
2408
2602
  this.failedBeats += 1;
2409
- if (this.admitted && this.failedBeats >= ADMISSION_FAILED_BEATS) {
2603
+ if (this.admitted && this.failedBeats >= ADMISSION_FAILED_REQUESTS) {
2410
2604
  this.admitted.reject(new Error("Lost contact with the server while waiting", {
2411
2605
  cause: error
2412
2606
  }));
@@ -2445,14 +2639,17 @@ class NuanceConversation {
2445
2639
  if (state.status !== "queued" || state.position == null) {
2446
2640
  return;
2447
2641
  }
2642
+ this.reportPlace(state.position, state.estimated_wait_seconds ?? null, state.pool_slots ?? null);
2643
+ }
2644
+ reportPlace(position, estimate, poolSlots) {
2448
2645
  if (this._state !== "queued") {
2449
2646
  this.setState("queued");
2450
2647
  }
2451
2648
  this.emit({
2452
2649
  type: "queue-changed",
2453
- position: state.position,
2454
- estimatedWaitSeconds: this.smoothEstimate(state.position, state.estimated_wait_seconds ?? null),
2455
- poolSlots: state.pool_slots ?? null
2650
+ position,
2651
+ estimatedWaitSeconds: this.smoothEstimate(position, estimate),
2652
+ poolSlots
2456
2653
  });
2457
2654
  }
2458
2655
  smoothEstimate(position, estimate) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuancelabs/js-sdk",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "JavaScript SDK for Nuance Labs conversation video AI avatar",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {