@spatius/avatarkit 1.1.0-beta.1 → 1.2.0-beta.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/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.2.0-beta.1] - 2026-06-19
9
+
10
+ ### Added
11
+ - Configurable frame-starvation handling. New public API:
12
+ - `FrameStarvationMode` enum with `audioIndependent` (default) and `strictSync`.
13
+ - `AvatarController.frameStarvationMode: FrameStarvationMode` — choose how playback behaves when animation frames can't keep up with audio. `audioIndependent` keeps audio playing while animation catches up (previous behavior); `strictSync` pauses audio until frames arrive, keeping audio and animation strictly in sync.
14
+ - `AvatarController.onPlaybackStall: ((stalled: boolean) => void) | null` — fires when audio is paused/resumed due to frame starvation (only in `strictSync`).
15
+
16
+ ### Changed
17
+ - Avatars now load via the latest avatar asset format.
18
+ - CDN selection now adapts to the configured region.
19
+
8
20
  ## [1.1.0-beta.1] - 2026-06-08
9
21
 
10
22
  First 1.1 pre-release. Includes breaking changes — see below.
@@ -1,7 +1,7 @@
1
1
  var __defProp = Object.defineProperty;
2
2
  var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
3
3
  var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
4
- import { A as APP_CONFIG, l as logger, e as errorToMessage, a as logEvent } from "./index-CVQn5uEB.js";
4
+ import { A as APP_CONFIG, l as logger, e as errorToMessage, a as logEvent } from "./index-DHCQ9wsL.js";
5
5
  class StreamingAudioPlayer {
6
6
  // Mark if AudioContext is being resumed, avoid concurrent resume requests
7
7
  constructor(options) {
@@ -1,5 +1,5 @@
1
1
  import { Avatar } from './Avatar';
2
- import { ConnectionState, AvatarError, DrivingServiceMode, ConversationState, AnimationType, PostProcessingConfig } from '../types';
2
+ import { ConnectionState, AvatarError, DrivingServiceMode, FrameStarvationMode, ConversationState, AnimationType, PostProcessingConfig } from '../types';
3
3
  import { FrameRateInfo } from '../performance/FrameRateMonitor';
4
4
  export declare class AvatarController {
5
5
  private networkLayer?;
@@ -12,6 +12,21 @@ export declare class AvatarController {
12
12
  onError: ((error: AvatarError) => void) | null;
13
13
  /** Callback for animation type changes (e.g., idle → mono in fallback mode). Aligned with iOS/Android AvatarController.onAnimationState. */
14
14
  onAnimationState: ((type: AnimationType) => void) | null;
15
+ /**
16
+ * Strategy for handling animation-frame starvation. Default is
17
+ * {@link FrameStarvationMode.audioIndependent} (audio keeps playing, starvation only
18
+ * reported as telemetry — historical behavior). Set to {@link FrameStarvationMode.strictSync}
19
+ * to pause audio when frames run out and resume on new frames, notified via {@link onPlaybackStall}.
20
+ * Aligned with iOS/Android AvatarController.frameStarvationMode.
21
+ */
22
+ frameStarvationMode: FrameStarvationMode;
23
+ /**
24
+ * Fires when audio is paused/resumed due to frame starvation. Only invoked in
25
+ * {@link FrameStarvationMode.strictSync}. `stalled=true` — frames ran out, audio paused;
26
+ * `stalled=false` — new frames arrived, audio resumed (or conversation ended / fell back).
27
+ * Aligned with iOS/Android AvatarController.onPlaybackStall.
28
+ */
29
+ onPlaybackStall: ((stalled: boolean) => void) | null;
15
30
  private eventListeners;
16
31
  private readonly frameRateMonitor;
17
32
  /** Frame rate monitoring callback. Fires with aggregated metrics from a 2-second sliding window. */
@@ -32,6 +47,18 @@ export declare class AvatarController {
32
47
  private isFallbackMode;
33
48
  private frameStarvationEvents;
34
49
  private isFrameStarved;
50
+ /**
51
+ * Whether this round's final animation batch (ServerResponseAnimation.end) has arrived.
52
+ * Frame starvation is only possible BEFORE this — once all frames are in, any remaining
53
+ * audio tail is normal end-of-round, not starvation, so audio must keep playing to idle
54
+ * and must never be paused (matters most in strictSync). Reset per conversation.
55
+ */
56
+ private animationEnded;
57
+ /**
58
+ * Whether audio is currently paused because of frame starvation (strictSync only).
59
+ * Orthogonal to user pause — user pause/resume does not change it; only frame arrival does.
60
+ */
61
+ private isAudioStalledForStarvation;
35
62
  private playbackStuckCheckState;
36
63
  private readonly MAX_AUDIO_TIME_ZERO_COUNT;
37
64
  private readonly MAX_AUDIO_TIME_STUCK_COUNT;
@@ -1257,6 +1257,7 @@ function messageTypeToJSON(object) {
1257
1257
  }
1258
1258
  var AudioFormat = /* @__PURE__ */ ((AudioFormat2) => {
1259
1259
  AudioFormat2[AudioFormat2["AUDIO_FORMAT_PCM_S16LE"] = 0] = "AUDIO_FORMAT_PCM_S16LE";
1260
+ AudioFormat2[AudioFormat2["AUDIO_FORMAT_OGG_OPUS"] = 1] = "AUDIO_FORMAT_OGG_OPUS";
1260
1261
  AudioFormat2[AudioFormat2["UNRECOGNIZED"] = -1] = "UNRECOGNIZED";
1261
1262
  return AudioFormat2;
1262
1263
  })(AudioFormat || {});
@@ -1265,6 +1266,9 @@ function audioFormatFromJSON(object) {
1265
1266
  case 0:
1266
1267
  case "AUDIO_FORMAT_PCM_S16LE":
1267
1268
  return 0;
1269
+ case 1:
1270
+ case "AUDIO_FORMAT_OGG_OPUS":
1271
+ return 1;
1268
1272
  case -1:
1269
1273
  case "UNRECOGNIZED":
1270
1274
  default:
@@ -1275,6 +1279,8 @@ function audioFormatToJSON(object) {
1275
1279
  switch (object) {
1276
1280
  case 0:
1277
1281
  return "AUDIO_FORMAT_PCM_S16LE";
1282
+ case 1:
1283
+ return "AUDIO_FORMAT_OGG_OPUS";
1278
1284
  case -1:
1279
1285
  default:
1280
1286
  return "UNRECOGNIZED";
@@ -1343,7 +1349,16 @@ function egressTypeToJSON(object) {
1343
1349
  }
1344
1350
  }
1345
1351
  function createBaseLiveKitEgressConfig() {
1346
- return { url: "", apiKey: "", apiSecret: "", roomName: "", publisherId: "" };
1352
+ return {
1353
+ url: "",
1354
+ apiKey: "",
1355
+ apiSecret: "",
1356
+ roomName: "",
1357
+ publisherId: "",
1358
+ extraAttributes: {},
1359
+ idleTimeout: 0,
1360
+ apiToken: ""
1361
+ };
1347
1362
  }
1348
1363
  const LiveKitEgressConfig = {
1349
1364
  encode(message, writer = new BinaryWriter()) {
@@ -1362,6 +1377,15 @@ const LiveKitEgressConfig = {
1362
1377
  if (message.publisherId !== "") {
1363
1378
  writer.uint32(42).string(message.publisherId);
1364
1379
  }
1380
+ globalThis.Object.entries(message.extraAttributes).forEach(([key, value]) => {
1381
+ LiveKitEgressConfig_ExtraAttributesEntry.encode({ key, value }, writer.uint32(50).fork()).join();
1382
+ });
1383
+ if (message.idleTimeout !== 0) {
1384
+ writer.uint32(56).int32(message.idleTimeout);
1385
+ }
1386
+ if (message.apiToken !== "") {
1387
+ writer.uint32(66).string(message.apiToken);
1388
+ }
1365
1389
  return writer;
1366
1390
  },
1367
1391
  decode(input, length) {
@@ -1406,6 +1430,30 @@ const LiveKitEgressConfig = {
1406
1430
  message.publisherId = reader.string();
1407
1431
  continue;
1408
1432
  }
1433
+ case 6: {
1434
+ if (tag !== 50) {
1435
+ break;
1436
+ }
1437
+ const entry6 = LiveKitEgressConfig_ExtraAttributesEntry.decode(reader, reader.uint32());
1438
+ if (entry6.value !== void 0) {
1439
+ message.extraAttributes[entry6.key] = entry6.value;
1440
+ }
1441
+ continue;
1442
+ }
1443
+ case 7: {
1444
+ if (tag !== 56) {
1445
+ break;
1446
+ }
1447
+ message.idleTimeout = reader.int32();
1448
+ continue;
1449
+ }
1450
+ case 8: {
1451
+ if (tag !== 66) {
1452
+ break;
1453
+ }
1454
+ message.apiToken = reader.string();
1455
+ continue;
1456
+ }
1409
1457
  }
1410
1458
  if ((tag & 7) === 4 || tag === 0) {
1411
1459
  break;
@@ -1420,7 +1468,22 @@ const LiveKitEgressConfig = {
1420
1468
  apiKey: isSet(object.apiKey) ? globalThis.String(object.apiKey) : isSet(object.api_key) ? globalThis.String(object.api_key) : "",
1421
1469
  apiSecret: isSet(object.apiSecret) ? globalThis.String(object.apiSecret) : isSet(object.api_secret) ? globalThis.String(object.api_secret) : "",
1422
1470
  roomName: isSet(object.roomName) ? globalThis.String(object.roomName) : isSet(object.room_name) ? globalThis.String(object.room_name) : "",
1423
- publisherId: isSet(object.publisherId) ? globalThis.String(object.publisherId) : isSet(object.publisher_id) ? globalThis.String(object.publisher_id) : ""
1471
+ publisherId: isSet(object.publisherId) ? globalThis.String(object.publisherId) : isSet(object.publisher_id) ? globalThis.String(object.publisher_id) : "",
1472
+ extraAttributes: isObject(object.extraAttributes) ? globalThis.Object.entries(object.extraAttributes).reduce(
1473
+ (acc, [key, value]) => {
1474
+ acc[key] = globalThis.String(value);
1475
+ return acc;
1476
+ },
1477
+ {}
1478
+ ) : isObject(object.extra_attributes) ? globalThis.Object.entries(object.extra_attributes).reduce(
1479
+ (acc, [key, value]) => {
1480
+ acc[key] = globalThis.String(value);
1481
+ return acc;
1482
+ },
1483
+ {}
1484
+ ) : {},
1485
+ idleTimeout: isSet(object.idleTimeout) ? globalThis.Number(object.idleTimeout) : isSet(object.idle_timeout) ? globalThis.Number(object.idle_timeout) : 0,
1486
+ apiToken: isSet(object.apiToken) ? globalThis.String(object.apiToken) : isSet(object.api_token) ? globalThis.String(object.api_token) : ""
1424
1487
  };
1425
1488
  },
1426
1489
  toJSON(message) {
@@ -1440,6 +1503,21 @@ const LiveKitEgressConfig = {
1440
1503
  if (message.publisherId !== "") {
1441
1504
  obj.publisherId = message.publisherId;
1442
1505
  }
1506
+ if (message.extraAttributes) {
1507
+ const entries = globalThis.Object.entries(message.extraAttributes);
1508
+ if (entries.length > 0) {
1509
+ obj.extraAttributes = {};
1510
+ entries.forEach(([k2, v2]) => {
1511
+ obj.extraAttributes[k2] = v2;
1512
+ });
1513
+ }
1514
+ }
1515
+ if (message.idleTimeout !== 0) {
1516
+ obj.idleTimeout = Math.round(message.idleTimeout);
1517
+ }
1518
+ if (message.apiToken !== "") {
1519
+ obj.apiToken = message.apiToken;
1520
+ }
1443
1521
  return obj;
1444
1522
  },
1445
1523
  create(base) {
@@ -1452,6 +1530,85 @@ const LiveKitEgressConfig = {
1452
1530
  message.apiSecret = object.apiSecret ?? "";
1453
1531
  message.roomName = object.roomName ?? "";
1454
1532
  message.publisherId = object.publisherId ?? "";
1533
+ message.extraAttributes = globalThis.Object.entries(object.extraAttributes ?? {}).reduce(
1534
+ (acc, [key, value]) => {
1535
+ if (value !== void 0) {
1536
+ acc[key] = globalThis.String(value);
1537
+ }
1538
+ return acc;
1539
+ },
1540
+ {}
1541
+ );
1542
+ message.idleTimeout = object.idleTimeout ?? 0;
1543
+ message.apiToken = object.apiToken ?? "";
1544
+ return message;
1545
+ }
1546
+ };
1547
+ function createBaseLiveKitEgressConfig_ExtraAttributesEntry() {
1548
+ return { key: "", value: "" };
1549
+ }
1550
+ const LiveKitEgressConfig_ExtraAttributesEntry = {
1551
+ encode(message, writer = new BinaryWriter()) {
1552
+ if (message.key !== "") {
1553
+ writer.uint32(10).string(message.key);
1554
+ }
1555
+ if (message.value !== "") {
1556
+ writer.uint32(18).string(message.value);
1557
+ }
1558
+ return writer;
1559
+ },
1560
+ decode(input, length) {
1561
+ const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
1562
+ const end = length === void 0 ? reader.len : reader.pos + length;
1563
+ const message = createBaseLiveKitEgressConfig_ExtraAttributesEntry();
1564
+ while (reader.pos < end) {
1565
+ const tag = reader.uint32();
1566
+ switch (tag >>> 3) {
1567
+ case 1: {
1568
+ if (tag !== 10) {
1569
+ break;
1570
+ }
1571
+ message.key = reader.string();
1572
+ continue;
1573
+ }
1574
+ case 2: {
1575
+ if (tag !== 18) {
1576
+ break;
1577
+ }
1578
+ message.value = reader.string();
1579
+ continue;
1580
+ }
1581
+ }
1582
+ if ((tag & 7) === 4 || tag === 0) {
1583
+ break;
1584
+ }
1585
+ reader.skip(tag & 7);
1586
+ }
1587
+ return message;
1588
+ },
1589
+ fromJSON(object) {
1590
+ return {
1591
+ key: isSet(object.key) ? globalThis.String(object.key) : "",
1592
+ value: isSet(object.value) ? globalThis.String(object.value) : ""
1593
+ };
1594
+ },
1595
+ toJSON(message) {
1596
+ const obj = {};
1597
+ if (message.key !== "") {
1598
+ obj.key = message.key;
1599
+ }
1600
+ if (message.value !== "") {
1601
+ obj.value = message.value;
1602
+ }
1603
+ return obj;
1604
+ },
1605
+ create(base) {
1606
+ return LiveKitEgressConfig_ExtraAttributesEntry.fromPartial(base ?? {});
1607
+ },
1608
+ fromPartial(object) {
1609
+ const message = createBaseLiveKitEgressConfig_ExtraAttributesEntry();
1610
+ message.key = object.key ?? "";
1611
+ message.value = object.value ?? "";
1455
1612
  return message;
1456
1613
  }
1457
1614
  };
@@ -3999,6 +4156,11 @@ var DrivingServiceMode = /* @__PURE__ */ ((DrivingServiceMode2) => {
3999
4156
  DrivingServiceMode2["backend"] = "backend";
4000
4157
  return DrivingServiceMode2;
4001
4158
  })(DrivingServiceMode || {});
4159
+ var FrameStarvationMode = /* @__PURE__ */ ((FrameStarvationMode2) => {
4160
+ FrameStarvationMode2["audioIndependent"] = "audioIndependent";
4161
+ FrameStarvationMode2["strictSync"] = "strictSync";
4162
+ return FrameStarvationMode2;
4163
+ })(FrameStarvationMode || {});
4002
4164
  var LogLevel = /* @__PURE__ */ ((LogLevel2) => {
4003
4165
  LogLevel2["off"] = "off";
4004
4166
  LogLevel2["error"] = "error";
@@ -12349,6 +12511,11 @@ function convertWasmParamsToProtoFlame(wasmParams) {
12349
12511
  expression: wasmParams.expr_params || []
12350
12512
  };
12351
12513
  }
12514
+ const GLOBAL_FLAME_CDN_BASE = "https://cdn.spatialwalk.cloud/public";
12515
+ const CN_FLAME_CDN_BASE = "https://cdn.spatialwalk.top/public";
12516
+ function getFlameCdnBase(region) {
12517
+ return region.startsWith("cn-") ? CN_FLAME_CDN_BASE : GLOBAL_FLAME_CDN_BASE;
12518
+ }
12352
12519
  const APP_CONFIG = {
12353
12520
  // Dynamic debug mode check (includes URL parameter)
12354
12521
  get debug() {
@@ -12371,7 +12538,6 @@ const APP_CONFIG = {
12371
12538
  },
12372
12539
  // Unified template model CDN (single compressed model shared by all characters)
12373
12540
  flame: {
12374
- cdnBase: "https://cdn.spatialwalk.cloud/public",
12375
12541
  unifiedModelPath: "base_model.pb.gz"
12376
12542
  }
12377
12543
  };
@@ -12465,7 +12631,7 @@ const _AnimationPlayer = class _AnimationPlayer {
12465
12631
  if (this.streamingPlayer) {
12466
12632
  return;
12467
12633
  }
12468
- const { StreamingAudioPlayer } = await import("./StreamingAudioPlayer-DrHXn-YA.js");
12634
+ const { StreamingAudioPlayer } = await import("./StreamingAudioPlayer-Br8UrVgO.js");
12469
12635
  const { AvatarSDK: AvatarSDK2 } = await Promise.resolve().then(() => AvatarSDK$1);
12470
12636
  const audioFormat = AvatarSDK2.getAudioFormat();
12471
12637
  this.streamingPlayer = new StreamingAudioPlayer({
@@ -14525,7 +14691,7 @@ class AvatarSDK {
14525
14691
  __publicField(AvatarSDK, "_initializationState", "uninitialized");
14526
14692
  __publicField(AvatarSDK, "_initializingPromise", null);
14527
14693
  __publicField(AvatarSDK, "_configuration", null);
14528
- __publicField(AvatarSDK, "_version", "1.1.0-beta.1");
14694
+ __publicField(AvatarSDK, "_version", "1.2.0-beta.1");
14529
14695
  __publicField(AvatarSDK, "_avatarCore", null);
14530
14696
  __publicField(AvatarSDK, "_cachedDeviceScore", null);
14531
14697
  __publicField(AvatarSDK, "_rendererBackend", null);
@@ -14576,6 +14742,40 @@ class EventEmitter {
14576
14742
  }
14577
14743
  }
14578
14744
  }
14745
+ class PendingAudioBuffer {
14746
+ constructor() {
14747
+ __publicField(this, "items", []);
14748
+ }
14749
+ get isEmpty() {
14750
+ return this.items.length === 0;
14751
+ }
14752
+ get count() {
14753
+ return this.items.length;
14754
+ }
14755
+ /** Buffer a chunk sent while not yet able to reach the server. */
14756
+ enqueue(item) {
14757
+ this.items.push(item);
14758
+ }
14759
+ /**
14760
+ * Drain the buffer, returning only the latest conversation's chunks (in
14761
+ * arrival order) for replay and dropping anything from earlier conversations.
14762
+ * The buffer is empty afterwards.
14763
+ */
14764
+ flush() {
14765
+ const buffered = this.items;
14766
+ this.items = [];
14767
+ if (buffered.length === 0) {
14768
+ return { toReplay: [], dropped: 0 };
14769
+ }
14770
+ const currentId = buffered[buffered.length - 1].conversationId;
14771
+ const toReplay = buffered.filter((it2) => it2.conversationId === currentId);
14772
+ return { toReplay, dropped: buffered.length - toReplay.length };
14773
+ }
14774
+ /** Drop everything (conversation ended / explicit disconnect). */
14775
+ clear() {
14776
+ this.items = [];
14777
+ }
14778
+ }
14579
14779
  class AnimationWebSocketClient extends EventEmitter {
14580
14780
  constructor(options) {
14581
14781
  super();
@@ -14593,6 +14793,13 @@ class AnimationWebSocketClient extends EventEmitter {
14593
14793
  __publicField(this, "sessionConfigured", false);
14594
14794
  // v2 protocol: mark if session is configured
14595
14795
  __publicField(this, "connectionStartTime", 0);
14796
+ /**
14797
+ * Direct mode: audio sent before the session is confirmed is buffered here and
14798
+ * replayed (in order) the moment the session is confirmed, so the server's
14799
+ * audio stream stays identical to what played locally. See PendingAudioBuffer
14800
+ * for the rationale and conversation-boundary handling.
14801
+ */
14802
+ __publicField(this, "pendingAudioBuffer", new PendingAudioBuffer());
14596
14803
  this.wsUrl = options.wsUrl;
14597
14804
  this.reconnectAttempts = options.reconnectAttempts ?? 5;
14598
14805
  this.jwtToken = options.jwtToken;
@@ -14637,6 +14844,7 @@ class AnimationWebSocketClient extends EventEmitter {
14637
14844
  this.ws.close(1e3, "Normal closure");
14638
14845
  this.ws = null;
14639
14846
  }
14847
+ this.pendingAudioBuffer.clear();
14640
14848
  idManager.clearConnectionId();
14641
14849
  this.removeAllListeners();
14642
14850
  this.currentRetryCount = 0;
@@ -14655,14 +14863,18 @@ class AnimationWebSocketClient extends EventEmitter {
14655
14863
  * @internal
14656
14864
  */
14657
14865
  sendAudioData(conversationId, audioData, end) {
14658
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
14659
- logger.warn("[AnimationWebSocketClient] WebSocket not connected, skipping audio send");
14660
- return false;
14661
- }
14662
- if (!this.sessionConfigured) {
14663
- logger.warn("[AnimationWebSocketClient] Session not configured yet, skipping audio send");
14866
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.sessionConfigured) {
14867
+ logger.warn("[AnimationWebSocketClient] Not ready (socket/session), buffering audio for replay on connect");
14868
+ this.pendingAudioBuffer.enqueue({ conversationId, audioData, end });
14664
14869
  return false;
14665
14870
  }
14871
+ return this.encodeAndSendAudio(conversationId, audioData, end);
14872
+ }
14873
+ /**
14874
+ * Encode and write one audio chunk to the open socket. Caller must ensure the
14875
+ * socket is open and the session is configured.
14876
+ */
14877
+ encodeAndSendAudio(conversationId, audioData, end) {
14666
14878
  try {
14667
14879
  const message = {
14668
14880
  type: MessageType.MESSAGE_CLIENT_AUDIO_INPUT,
@@ -14689,6 +14901,23 @@ class AnimationWebSocketClient extends EventEmitter {
14689
14901
  return false;
14690
14902
  }
14691
14903
  }
14904
+ /**
14905
+ * Replay audio buffered before the session was confirmed, in arrival order,
14906
+ * now that it's confirmed. Only the latest conversation's audio is replayed so
14907
+ * the server's audio stream matches what played locally; earlier conversations
14908
+ * are dropped. Aligned with iOS / Android flushPendingAudioMessages.
14909
+ */
14910
+ flushPendingAudio() {
14911
+ if (this.pendingAudioBuffer.isEmpty) return;
14912
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.sessionConfigured) return;
14913
+ const { toReplay, dropped } = this.pendingAudioBuffer.flush();
14914
+ logger.log(
14915
+ `[AnimationWebSocketClient] Flushing ${toReplay.length} buffered audio chunk(s) to driving service.` + (dropped > 0 ? ` Dropped ${dropped} from earlier conversation(s).` : "")
14916
+ );
14917
+ for (const item of toReplay) {
14918
+ this.encodeAndSendAudio(item.conversationId, item.audioData, item.end);
14919
+ }
14920
+ }
14692
14921
  /**
14693
14922
  * Generate conversation ID
14694
14923
  * Uses unified conversation ID generation rule: YYYYMMDDHHmmss_nanoid
@@ -14937,6 +15166,7 @@ class AnimationWebSocketClient extends EventEmitter {
14937
15166
  } else {
14938
15167
  logger.log("[AnimationWebSocketClient] Session confirmed by server");
14939
15168
  }
15169
+ this.flushPendingAudio();
14940
15170
  this.emit("sessionConfirmed", connectionId);
14941
15171
  return;
14942
15172
  }
@@ -15212,7 +15442,7 @@ class NetworkLayer {
15212
15442
  logger.error("[NetworkLayer] Invalid animation message");
15213
15443
  return;
15214
15444
  }
15215
- const { reqId, animation, avatarId } = message.serverResponseAnimation;
15445
+ const { reqId, animation, avatarId, end } = message.serverResponseAnimation;
15216
15446
  const conversationId = reqId;
15217
15447
  if (avatarId && avatarId !== this.dataController.getAvatarId()) {
15218
15448
  logger.error(`[NetworkLayer] Ignoring animation data for mismatched avatar - expected: ${this.dataController.getAvatarId()}, received: ${avatarId}`);
@@ -15239,7 +15469,7 @@ class NetworkLayer {
15239
15469
  if ((animation == null ? void 0 : animation.keyframes) && animation.keyframes.length > 0) {
15240
15470
  const keyframes = animation.keyframes;
15241
15471
  try {
15242
- this.dataController.yieldKeyframes(keyframes, conversationId);
15472
+ this.dataController.yieldKeyframes(keyframes, conversationId, end);
15243
15473
  } catch (error) {
15244
15474
  const errorMessage = error instanceof Error ? error.message : String(error);
15245
15475
  logger.error(`[NetworkLayer] Failed to yield animation data: ${errorMessage}`);
@@ -15606,6 +15836,21 @@ class AvatarController {
15606
15836
  __publicField(this, "onError", null);
15607
15837
  /** Callback for animation type changes (e.g., idle → mono in fallback mode). Aligned with iOS/Android AvatarController.onAnimationState. */
15608
15838
  __publicField(this, "onAnimationState", null);
15839
+ /**
15840
+ * Strategy for handling animation-frame starvation. Default is
15841
+ * {@link FrameStarvationMode.audioIndependent} (audio keeps playing, starvation only
15842
+ * reported as telemetry — historical behavior). Set to {@link FrameStarvationMode.strictSync}
15843
+ * to pause audio when frames run out and resume on new frames, notified via {@link onPlaybackStall}.
15844
+ * Aligned with iOS/Android AvatarController.frameStarvationMode.
15845
+ */
15846
+ __publicField(this, "frameStarvationMode", FrameStarvationMode.audioIndependent);
15847
+ /**
15848
+ * Fires when audio is paused/resumed due to frame starvation. Only invoked in
15849
+ * {@link FrameStarvationMode.strictSync}. `stalled=true` — frames ran out, audio paused;
15850
+ * `stalled=false` — new frames arrived, audio resumed (or conversation ended / fell back).
15851
+ * Aligned with iOS/Android AvatarController.onPlaybackStall.
15852
+ */
15853
+ __publicField(this, "onPlaybackStall", null);
15609
15854
  __publicField(this, "eventListeners", /* @__PURE__ */ new Map());
15610
15855
  // ========== Performance Monitoring ==========
15611
15856
  __publicField(this, "frameRateMonitor", new FrameRateMonitor());
@@ -15634,6 +15879,18 @@ class AvatarController {
15634
15879
  // ========== Frame Starvation Tracking ==========
15635
15880
  __publicField(this, "frameStarvationEvents", []);
15636
15881
  __publicField(this, "isFrameStarved", false);
15882
+ /**
15883
+ * Whether this round's final animation batch (ServerResponseAnimation.end) has arrived.
15884
+ * Frame starvation is only possible BEFORE this — once all frames are in, any remaining
15885
+ * audio tail is normal end-of-round, not starvation, so audio must keep playing to idle
15886
+ * and must never be paused (matters most in strictSync). Reset per conversation.
15887
+ */
15888
+ __publicField(this, "animationEnded", false);
15889
+ /**
15890
+ * Whether audio is currently paused because of frame starvation (strictSync only).
15891
+ * Orthogonal to user pause — user pause/resume does not change it; only frame arrival does.
15892
+ */
15893
+ __publicField(this, "isAudioStalledForStarvation", false);
15637
15894
  // ========== Playback Stuck Detection ==========
15638
15895
  __publicField(this, "playbackStuckCheckState", {
15639
15896
  audioTimeZeroCount: 0,
@@ -16151,7 +16408,7 @@ class AvatarController {
16151
16408
  if (allKeyframes.length === 0) {
16152
16409
  logger.warn(`[AvatarController] No keyframes decoded from ${keyframesDataArray.length} message chunks`);
16153
16410
  }
16154
- this.yieldKeyframes(allKeyframes, conversationId);
16411
+ this.yieldKeyframes(allKeyframes, conversationId, isEnd);
16155
16412
  return isEnd;
16156
16413
  }
16157
16414
  /**
@@ -16159,12 +16416,15 @@ class AvatarController {
16159
16416
  * External consumers should use `yieldFramesData()` instead.
16160
16417
  * @internal
16161
16418
  */
16162
- yieldKeyframes(keyframes, conversationId) {
16163
- var _a;
16419
+ yieldKeyframes(keyframes, conversationId, isEnd = false) {
16420
+ var _a, _b;
16164
16421
  if (!conversationId || typeof conversationId !== "string") {
16165
16422
  logger.error(`[AvatarController] yieldKeyframes requires a valid conversationId. The conversationId is returned by yieldAudioData().`);
16166
16423
  return;
16167
16424
  }
16425
+ if (isEnd) {
16426
+ this.animationEnded = true;
16427
+ }
16168
16428
  const expectedConversationId = this.getEffectiveConversationId();
16169
16429
  if (!expectedConversationId || conversationId !== expectedConversationId) {
16170
16430
  logger.warn(`[AvatarController] Ignoring mismatched animation data - expected conversationId: ${expectedConversationId}, received conversationId: ${conversationId}`);
@@ -16194,6 +16454,14 @@ class AvatarController {
16194
16454
  this.currentKeyframes.push(...flameKeyframes);
16195
16455
  }
16196
16456
  this.emit("keyframesUpdate", this.currentKeyframes);
16457
+ if (this.isAudioStalledForStarvation) {
16458
+ const audioTime = ((_b = this.animationPlayer) == null ? void 0 : _b.getCurrentTime()) ?? 0;
16459
+ const frameIndex = Math.round(audioTime * FLAME_FRAME_RATE);
16460
+ const arrayIndex = frameIndex - this.keyframesOffset;
16461
+ if (arrayIndex >= 0 && arrayIndex < this.currentKeyframes.length) {
16462
+ this.resumeAudioFromStarvation();
16463
+ }
16464
+ }
16197
16465
  if (!this.isPlaying && !this.isStartingPlayback && this.pendingAudioChunks.length > 0 && this.currentKeyframes.length > 0) {
16198
16466
  this.startStreamingPlayback().catch((error) => {
16199
16467
  var _a2;
@@ -16230,7 +16498,9 @@ class AvatarController {
16230
16498
  return;
16231
16499
  }
16232
16500
  logger.log("[AvatarController] Resuming playback");
16233
- await ((_a = this.animationPlayer) == null ? void 0 : _a.resume());
16501
+ if (!this.isAudioStalledForStarvation) {
16502
+ await ((_a = this.animationPlayer) == null ? void 0 : _a.resume());
16503
+ }
16234
16504
  this.currentState = AvatarState.playing;
16235
16505
  this.notifyConversationState(AvatarState.playing);
16236
16506
  logger.log("[AvatarController] Playback resumed");
@@ -16305,6 +16575,7 @@ class AvatarController {
16305
16575
  * @internal
16306
16576
  */
16307
16577
  clearPlaybackData() {
16578
+ var _a;
16308
16579
  this.currentKeyframes = [];
16309
16580
  this.pendingAudioChunks = [];
16310
16581
  this.lastRenderedFrameIndex = -1;
@@ -16312,6 +16583,11 @@ class AvatarController {
16312
16583
  this.isFallbackMode = false;
16313
16584
  this.lastSyncLogTime = 0;
16314
16585
  this.lastOutOfBoundsState = false;
16586
+ this.animationEnded = false;
16587
+ if (this.isAudioStalledForStarvation) {
16588
+ this.isAudioStalledForStarvation = false;
16589
+ (_a = this.onPlaybackStall) == null ? void 0 : _a.call(this, false);
16590
+ }
16315
16591
  if (this.playbackMode === DrivingServiceMode.backend) {
16316
16592
  this.hostModeMetrics = {
16317
16593
  accumulatedBytes: 0,
@@ -16574,6 +16850,13 @@ class AvatarController {
16574
16850
  const hasAnimationData = this.currentKeyframes.length > 0;
16575
16851
  const hasAudioData = ((_b = streamingPlayer.audioChunks) == null ? void 0 : _b.length) > 0;
16576
16852
  const isNotPaused = this.currentState !== AvatarState.paused;
16853
+ if (this.isAudioStalledForStarvation) {
16854
+ state.audioTimeZeroCount = 0;
16855
+ state.audioTimeStuckCount = 0;
16856
+ state.lastAudioTime = 0;
16857
+ state.reported = false;
16858
+ return false;
16859
+ }
16577
16860
  if (!hasAnimationData || !hasAudioData || this.currentState === AvatarState.paused) {
16578
16861
  state.audioTimeZeroCount = 0;
16579
16862
  state.audioTimeStuckCount = 0;
@@ -16677,13 +16960,49 @@ class AvatarController {
16677
16960
  this.frameStarvationEvents.push({ audioTime, reqEnd: this.reqEnd });
16678
16961
  }
16679
16962
  }
16963
+ if (!this.animationEnded) {
16964
+ this.pauseAudioForStarvation();
16965
+ }
16680
16966
  } else {
16681
16967
  this.isFrameStarved = false;
16682
16968
  if (isOutOfBounds !== this.lastOutOfBoundsState) {
16683
16969
  this.lastOutOfBoundsState = isOutOfBounds;
16684
16970
  }
16971
+ this.resumeAudioFromStarvation();
16685
16972
  }
16686
16973
  }
16974
+ /**
16975
+ * strictSync: pause audio on frame starvation, waiting for new frames.
16976
+ *
16977
+ * The default (audioIndependent) keeps audio running and lets animation catch up;
16978
+ * strictSync instead pauses audio so audio/video stay strictly in sync. Idempotent;
16979
+ * only fires onPlaybackStall once. No-op unless mode is strictSync.
16980
+ * @internal
16981
+ */
16982
+ pauseAudioForStarvation() {
16983
+ var _a, _b;
16984
+ if (this.frameStarvationMode !== FrameStarvationMode.strictSync) return;
16985
+ if (this.isAudioStalledForStarvation) return;
16986
+ if (!this.isPlaying) return;
16987
+ (_a = this.animationPlayer) == null ? void 0 : _a.pause();
16988
+ this.isAudioStalledForStarvation = true;
16989
+ logger.log("[AvatarController] Frame starvation: paused audio, waiting for frames.");
16990
+ (_b = this.onPlaybackStall) == null ? void 0 : _b.call(this, true);
16991
+ }
16992
+ /**
16993
+ * strictSync: frame starvation resolved (new frames arrived). Resumes audio and fires
16994
+ * onPlaybackStall(false). Idempotent — returns immediately if not stalled (so it is a
16995
+ * no-op in audioIndependent, where isAudioStalledForStarvation stays false).
16996
+ * @internal
16997
+ */
16998
+ resumeAudioFromStarvation() {
16999
+ var _a, _b;
17000
+ if (!this.isAudioStalledForStarvation) return;
17001
+ this.isAudioStalledForStarvation = false;
17002
+ void ((_a = this.animationPlayer) == null ? void 0 : _a.resume());
17003
+ logger.log("[AvatarController] Frame starvation recovered: resumed audio.");
17004
+ (_b = this.onPlaybackStall) == null ? void 0 : _b.call(this, false);
17005
+ }
16687
17006
  /**
16688
17007
  * Start playback loop
16689
17008
  * @internal
@@ -16729,6 +17048,7 @@ class AvatarController {
16729
17048
  }
16730
17049
  logger.warn("[AvatarController] Enabling fallback mode");
16731
17050
  this.isFallbackMode = true;
17051
+ this.resumeAudioFromStarvation();
16732
17052
  logEvent("fallback_mode_entered", "warning", {
16733
17053
  avatar_id: this.avatar.id,
16734
17054
  reason,
@@ -17253,9 +17573,12 @@ class AvatarDownloader {
17253
17573
  * @internal
17254
17574
  */
17255
17575
  async loadUnifiedTemplate() {
17576
+ var _a;
17256
17577
  await PwaCacheManager.checkTemplateCacheVersion();
17257
17578
  const startTime = Date.now();
17258
- const { cdnBase, unifiedModelPath } = APP_CONFIG.flame;
17579
+ const region = ((_a = AvatarSDK.configuration) == null ? void 0 : _a.region) || DEFAULT_REGION;
17580
+ const cdnBase = getFlameCdnBase(region);
17581
+ const { unifiedModelPath } = APP_CONFIG.flame;
17259
17582
  const url = `${cdnBase}/${unifiedModelPath}`;
17260
17583
  logger.log(`📥 Loading unified template from: ${url}`);
17261
17584
  const cached = await PwaCacheManager.getTemplateResource(url);
@@ -17526,7 +17849,7 @@ class AvatarDownloader {
17526
17849
  }
17527
17850
  let error;
17528
17851
  if (response.status === 404) {
17529
- const urlMatch = url.match(/\/v2\/character\/([^/?]+)/);
17852
+ const urlMatch = url.match(/\/v2\/(?:character|avatar)\/([^/?]+)/);
17530
17853
  const extractedCharacterId = urlMatch ? urlMatch[1] : "unknown";
17531
17854
  const callerHeaders = options.headers || {};
17532
17855
  const callerTraceId = callerHeaders["x-sp-trace-id"];
@@ -17562,10 +17885,50 @@ class AvatarDownloader {
17562
17885
  };
17563
17886
  }
17564
17887
  /**
17565
- * Get single character by ID from AvatarKit SDK API (v2, iOS compatible)
17888
+ * Map the new `/v2/avatar/{id}` `AvatarAsset` payload onto the internal
17889
+ * `CharacterMeta` shape used by the existing download / render pipeline.
17890
+ *
17891
+ * The backend (grpc-gateway) serialises proto fields as camelCase JSON, so the
17892
+ * runtime object is loosely shaped like the generated `AvatarAsset`. We:
17893
+ * - lift `models.gs` into `models.gsStandard` (downloader/asset-count read gsStandard)
17894
+ * - rename `animations.frameFallback` → `animations.frameMono`
17895
+ * - fold the inline `camera` / `transform` into `characterSettings` so the
17896
+ * renderer's `resolveCameraConfig` reads structured values and no camera
17897
+ * resource is downloaded (top-level `camera` is intentionally left unset)
17898
+ * @internal
17899
+ */
17900
+ mapAvatarAssetToCharacterMeta(asset, avatarId) {
17901
+ var _a, _b, _c, _d, _e2;
17902
+ const characterSettings = {
17903
+ ...asset.camera ? { camera: { ...asset.camera } } : {},
17904
+ ...asset.transform ? { transform: { ...asset.transform } } : {}
17905
+ };
17906
+ return {
17907
+ characterId: avatarId,
17908
+ version: asset.version ?? "",
17909
+ updatedAt: asset.updatedAt,
17910
+ models: {
17911
+ shape: (_a = asset.models) == null ? void 0 : _a.shape,
17912
+ gsStandard: (_b = asset.models) == null ? void 0 : _b.gs
17913
+ },
17914
+ animations: {
17915
+ frameIdle: (_c = asset.animations) == null ? void 0 : _c.frameIdle,
17916
+ frameMono: (_d = asset.animations) == null ? void 0 : _d.frameFallback
17917
+ },
17918
+ customAnimations: ((_e2 = asset.animations) == null ? void 0 : _e2.customAnimations) ?? [],
17919
+ characterSettings
17920
+ };
17921
+ }
17922
+ /**
17923
+ * Get single avatar by ID from AvatarKit SDK API (v2 driven-ingress avatar API).
17566
17924
  * Domain: composed from region as api.${region}.spatius.ai
17567
17925
  * Auth: Public endpoint, no authentication required
17568
- * Returns CharacterMeta with nested resource structure
17926
+ * Fetches the new `AvatarAsset` payload from `/v2/avatar/{id}` and maps it onto
17927
+ * the internal `CharacterMeta` shape consumed by the download / render pipeline:
17928
+ * - `models.gs` → `models.gsStandard`
17929
+ * - `animations.frameFallback` → `animations.frameMono`
17930
+ * - inline `camera` / `transform` → `characterSettings.{camera,transform}`
17931
+ * (so the renderer reads structured values directly and no camera resource is downloaded)
17569
17932
  * @internal
17570
17933
  */
17571
17934
  async getCharacterById(characterId, options) {
@@ -17578,7 +17941,7 @@ class AvatarDownloader {
17578
17941
  throw new Error("Request cancelled");
17579
17942
  }
17580
17943
  const client = this.getSdkApiClient();
17581
- const response = await client.request(`/v2/character/${characterId}`, {
17944
+ const response = await client.request(`/v2/avatar/${characterId}`, {
17582
17945
  method: "GET",
17583
17946
  headers: { "x-sp-trace-id": traceId },
17584
17947
  signal
@@ -17597,7 +17960,7 @@ class AvatarDownloader {
17597
17960
  duration,
17598
17961
  trace_id: traceId
17599
17962
  });
17600
- return response;
17963
+ return this.mapAvatarAssetToCharacterMeta(response, characterId);
17601
17964
  } catch (error) {
17602
17965
  if (error instanceof Error && (error.name === "AbortError" || error.message === "Request cancelled")) {
17603
17966
  logEvent("fetch_avatar_metadata_cancelled", "info", {
@@ -20793,6 +21156,10 @@ class AvatarView {
20793
21156
  },
20794
21157
  onPlayFallback: () => {
20795
21158
  this.playAnimation(AnimationType.idle, true);
21159
+ if (!this.cachedIdleFirstFrame) {
21160
+ this.getCachedIdleFirstFrame().catch(() => {
21161
+ });
21162
+ }
20796
21163
  this.isConversationActive = true;
20797
21164
  this.lastRenderedFrameIndex = -1;
20798
21165
  },
@@ -21465,6 +21832,7 @@ export {
21465
21832
  ConnectionState as C,
21466
21833
  DrivingServiceMode as D,
21467
21834
  ErrorCode as E,
21835
+ FrameStarvationMode as F,
21468
21836
  LogLevel as L,
21469
21837
  RenderQuality as R,
21470
21838
  TransitionType as T,
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { k, b, c, o, f, d, n, g, C, m, i, D, E, j, L, h, R, p, T, q } from "./index-CVQn5uEB.js";
1
+ import { k, b, c, o, f, d, n, g, C, m, i, D, E, F, j, L, h, R, p, T, q } from "./index-DHCQ9wsL.js";
2
2
  export {
3
3
  k as AnimationType,
4
4
  b as Avatar,
@@ -13,6 +13,7 @@ export {
13
13
  i as DEFAULT_REGION,
14
14
  D as DrivingServiceMode,
15
15
  E as ErrorCode,
16
+ F as FrameStarvationMode,
16
17
  j as LoadProgress,
17
18
  L as LogLevel,
18
19
  h as RENDER_QUALITY_PARAMS,
@@ -7,6 +7,19 @@ export declare enum DrivingServiceMode {
7
7
  /** Driven by host application */
8
8
  backend = "backend"
9
9
  }
10
+ /**
11
+ * Strategy for handling animation-frame starvation (animation frames can't keep up
12
+ * with the audio clock).
13
+ *
14
+ * - `audioIndependent` (default): audio keeps playing, animation catches up; starvation
15
+ * is only reported as telemetry. This is the historical default behavior.
16
+ * - `strictSync`: pause audio and wait when frames run out, resume once new frames
17
+ * arrive, notifying via `AvatarController.onPlaybackStall`.
18
+ */
19
+ export declare enum FrameStarvationMode {
20
+ audioIndependent = "audioIndependent",
21
+ strictSync = "strictSync"
22
+ }
10
23
  export declare enum LogLevel {
11
24
  /** Disable all logs */
12
25
  off = "off",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spatius/avatarkit",
3
3
  "type": "module",
4
- "version": "1.1.0-beta.1",
4
+ "version": "1.2.0-beta.1",
5
5
  "packageManager": "pnpm@10.18.2",
6
6
  "description": "AvatarKit SDK - 3D Gaussian Splatting Avatar Rendering SDK",
7
7
  "author": "AvatarKit Team",
@@ -48,6 +48,7 @@
48
48
  "build": "SDK_BUILD=true vite build --mode library && npm run build:vite-plugin && npm run build:next-plugin",
49
49
  "build:vite-plugin": "tsc vite.ts --outDir . --module esnext --target es2020 --moduleResolution bundler --esModuleInterop --skipLibCheck --declaration --declarationMap",
50
50
  "build:next-plugin": "tsc next.ts --outDir . --module esnext --target es2020 --moduleResolution bundler --esModuleInterop --skipLibCheck --declaration --declarationMap",
51
+ "prepare": "npm run build",
51
52
  "dev": "vite build --mode library --watch",
52
53
  "demo": "vite --config demo/vite.config.mjs",
53
54
  "demo:benchmark": "vite --config benchmark-demo/vite.config.mjs",