@spatius/avatarkit 1.3.0 → 1.3.1-beta.2

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,11 @@ 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.3.1-beta.1] - 2026-07-16
9
+
10
+ ### Changed
11
+ - The `region` configuration now defaults to automatic selection: when left unset, the SDK picks the closest serving region at initialization. Passing an explicit `region` continues to force that region, unchanged. If automatic selection can't be reached, the SDK falls back to a default region and continues initializing.
12
+
8
13
  ## [1.3.0] - 2026-07-04
9
14
 
10
15
  ### Added
@@ -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-BDFs1WRg.js";
4
+ import { A as APP_CONFIG, l as logger, e as errorToMessage, a as logEvent } from "./index-CPolDcOo.js";
5
5
  class StreamingAudioPlayer {
6
6
  // Mark if AudioContext is being resumed, avoid concurrent resume requests
7
7
  constructor(options) {
@@ -46,6 +46,8 @@ class StreamingAudioPlayer {
46
46
  // Default volume 1.0 (0.0 - 1.0)
47
47
  // Event callbacks
48
48
  __publicField(this, "onEndedCallback");
49
+ // 音频缓冲卡顿(缓冲耗尽自动暂停 / 有数据自动恢复)通知:true=进入卡顿,false=恢复。
50
+ __publicField(this, "onAudioStallCallback");
49
51
  // AudioContext state management
50
52
  __publicField(this, "stateChangeHandler");
51
53
  __publicField(this, "isResuming", false);
@@ -156,6 +158,7 @@ class StreamingAudioPlayer {
156
158
  * Add audio chunk (16-bit PCM)
157
159
  */
158
160
  addChunk(pcmData, isLast = false) {
161
+ var _a;
159
162
  if (!this.audioContext) {
160
163
  logger.error("AudioContext not initialized");
161
164
  return;
@@ -176,6 +179,7 @@ class StreamingAudioPlayer {
176
179
  if (this.autoContinue && this.isPaused) {
177
180
  this.log("[StreamingAudioPlayer] autoContinue=true, auto-resuming playback");
178
181
  this.autoContinue = false;
182
+ (_a = this.onAudioStallCallback) == null ? void 0 : _a.call(this, false);
179
183
  this.resume().catch((err) => {
180
184
  logger.error("Failed to auto-resume playback:", err);
181
185
  });
@@ -229,13 +233,13 @@ class StreamingAudioPlayer {
229
233
  this.log("[StreamingAudioPlayer] Cannot start playback: Already playing");
230
234
  return;
231
235
  }
232
- await this.ensureAudioContextRunning();
233
236
  this.isPlaying = true;
234
237
  this.sessionStartTime = this.audioContext.currentTime;
235
238
  this.scheduledTime = this.sessionStartTime;
236
239
  this.lastScheduledChunkEndTime = 0;
237
240
  this.scheduledChunkInfo = [];
238
241
  this.autoContinue = false;
242
+ await this.ensureAudioContextRunning();
239
243
  this.log("[StreamingAudioPlayer] Starting playback", {
240
244
  sessionStartTime: this.sessionStartTime,
241
245
  bufferedChunks: this.audioChunks.length,
@@ -304,18 +308,17 @@ class StreamingAudioPlayer {
304
308
  });
305
309
  this.activeSources.add(source);
306
310
  source.onended = () => {
311
+ var _a;
307
312
  this.activeSources.delete(source);
308
313
  if (this.activeSources.size === 0) {
309
314
  const lastChunk = this.audioChunks[this.scheduledChunks - 1];
310
- if (lastChunk && !lastChunk.isLast) {
315
+ const sessionEnded = !!(lastChunk == null ? void 0 : lastChunk.isLast);
316
+ if (!sessionEnded) {
311
317
  this.log("All audio chunks ended but end=false, pausing and setting autoContinue");
312
- logEvent("audio_buffer_stall", "warning", {
313
- scheduled_chunks: this.scheduledChunks,
314
- audio_time: this.getCurrentTime()
315
- });
318
+ (_a = this.onAudioStallCallback) == null ? void 0 : _a.call(this, true);
316
319
  this.autoContinue = true;
317
320
  this.pause();
318
- } else if (isLast) {
321
+ } else {
319
322
  this.log("Last audio chunk ended, marking playback as ended");
320
323
  this.markEnded();
321
324
  }
@@ -557,6 +560,12 @@ class StreamingAudioPlayer {
557
560
  onEnded(callback) {
558
561
  this.onEndedCallback = callback;
559
562
  }
563
+ /**
564
+ * 设置音频缓冲卡顿回调:缓冲耗尽自动暂停时 stalled=true,有数据自动恢复时 stalled=false。
565
+ */
566
+ onAudioStall(callback) {
567
+ this.onAudioStallCallback = callback;
568
+ }
560
569
  /**
561
570
  * Check if playing
562
571
  */
@@ -46,6 +46,11 @@ export declare class AvatarController {
46
46
  private isFallbackMode;
47
47
  private frameStarvationEvents;
48
48
  private isFrameStarved;
49
+ private audioStallEvents;
50
+ private isAudioBufferStalled;
51
+ private playbackEndReason;
52
+ private receivedAudioBytes;
53
+ private receivedAnimationFrames;
49
54
  /**
50
55
  * Whether this round's final animation batch (ServerResponseAnimation.end) has arrived.
51
56
  * Frame starvation is only possible BEFORE this — once all frames are in, any remaining
@@ -4189,6 +4189,7 @@ const RENDER_QUALITY_PARAMS = {
4189
4189
  ]: { renderScale: 1, splatRadius: 3 }
4190
4190
  };
4191
4191
  const DEFAULT_REGION = "us-west";
4192
+ const DEFAULT_REGION_REQUEST = "auto";
4192
4193
  var LoadProgress = /* @__PURE__ */ ((LoadProgress2) => {
4193
4194
  LoadProgress2["downloading"] = "downloading";
4194
4195
  LoadProgress2["completed"] = "completed";
@@ -11586,11 +11587,287 @@ class EventStore {
11586
11587
  }
11587
11588
  }
11588
11589
  const eventStore = new EventStore();
11590
+ const BOOTSTRAP_URL = "https://global.spatialwalk.top/bootstrap";
11591
+ async function fetchBootstrap(opts, signal, onResponse) {
11592
+ const res = await fetch(BOOTSTRAP_URL, {
11593
+ method: "POST",
11594
+ body: JSON.stringify({
11595
+ app_id: opts.appId,
11596
+ sdk_version: opts.sdkVersion,
11597
+ region: opts.region ?? "auto",
11598
+ platform: opts.platform ?? "web"
11599
+ }),
11600
+ cache: "no-store",
11601
+ signal
11602
+ });
11603
+ onResponse == null ? void 0 : onResponse();
11604
+ if (!res.ok) {
11605
+ throw new Error(`bootstrap HTTP ${res.status}`);
11606
+ }
11607
+ return await res.json();
11608
+ }
11609
+ const PROBE_TIMEOUT_MS = 5e3;
11610
+ const SAMPLE_COUNT = 3;
11611
+ const RECALIBRATE_INTERVAL_MS = 20 * 60 * 1e3;
11612
+ class ClockSync {
11613
+ constructor() {
11614
+ __publicField(this, "localBase", Date.now());
11615
+ __publicField(this, "serverBase", null);
11616
+ __publicField(this, "monoAtCalibrate", performance.now());
11617
+ /** 首次校准是否已落定(落定前埋点应挂队列) */
11618
+ __publicField(this, "calibrated", false);
11619
+ __publicField(this, "appId", "");
11620
+ __publicField(this, "sdkVersion", "");
11621
+ __publicField(this, "platform", "web");
11622
+ __publicField(this, "timer", null);
11623
+ __publicField(this, "visibilityHandler", null);
11624
+ __publicField(this, "syncing", false);
11625
+ /** 首次校准落定后触发的回调(供上报层 flush 挂起队列) */
11626
+ __publicField(this, "readyCallbacks", []);
11627
+ }
11628
+ /** 首次校准是否已完成(供上报闸门判断) */
11629
+ isReady() {
11630
+ return this.calibrated;
11631
+ }
11632
+ /**
11633
+ * 注册"首次校准落定"回调(成功或失败都算落定)。
11634
+ * 若已落定则立即同步触发一次。
11635
+ */
11636
+ onReady(cb) {
11637
+ if (this.calibrated) {
11638
+ cb();
11639
+ return;
11640
+ }
11641
+ this.readyCallbacks.push(cb);
11642
+ }
11643
+ fireReady() {
11644
+ const cbs = this.readyCallbacks;
11645
+ this.readyCallbacks = [];
11646
+ for (const cb of cbs) {
11647
+ try {
11648
+ cb();
11649
+ } catch {
11650
+ }
11651
+ }
11652
+ }
11653
+ /** 本地时间戳:本地基准 + 单调流逝。一定有值。 */
11654
+ localNow() {
11655
+ return this.localAt(performance.now());
11656
+ }
11657
+ /** 后台时间戳:后台基准 + 单调流逝。未校准到后台时返回 null(上报留空)。 */
11658
+ serverNow() {
11659
+ return this.serverAt(performance.now());
11660
+ }
11661
+ /**
11662
+ * 仅供单测:直接设定基准,绕过真实网络校准。
11663
+ * @internal
11664
+ */
11665
+ __setBaselineForTest(opts) {
11666
+ this.localBase = opts.localBase;
11667
+ this.serverBase = opts.serverBase;
11668
+ this.monoAtCalibrate = opts.monoAtCalibrate;
11669
+ this.calibrated = opts.calibrated ?? true;
11670
+ }
11671
+ /** 用"指定单调读数"算本地时间戳(供入队事件按入队时刻还原)。取整到毫秒。 */
11672
+ localAt(mono) {
11673
+ return Math.round(this.localBase + (mono - this.monoAtCalibrate));
11674
+ }
11675
+ /** 用"指定单调读数"算后台时间戳;未校准到后台时返回 null。取整到毫秒。 */
11676
+ serverAt(mono) {
11677
+ if (this.serverBase === null) return null;
11678
+ return Math.round(this.serverBase + (mono - this.monoAtCalibrate));
11679
+ }
11680
+ /**
11681
+ * 用时间戳做逻辑(端到端延迟 tap_N/anim_N 等)时用:优先后台时间戳,
11682
+ * 拿不到后台才退本地,保证与后台时刻同一时间线对齐。
11683
+ */
11684
+ timelineNow() {
11685
+ return this.serverNow() ?? this.localNow();
11686
+ }
11687
+ /**
11688
+ * 把"采集时刻的单调读数"换算成时间线时刻(优先后台、退本地)。
11689
+ * 供标记解析用——采集时若校准未完成,先记单调读数,上报时再用最新基准换算。
11690
+ * @param mono 采集那一刻的 performance.now()
11691
+ */
11692
+ resolveMono(mono) {
11693
+ const drift = mono - this.monoAtCalibrate;
11694
+ const base = this.serverBase ?? this.localBase;
11695
+ return Math.round(base + drift);
11696
+ }
11697
+ /**
11698
+ * 启动:立即校准一次,并注册定时 + 页面可见性重校准。
11699
+ * 幂等;fire-and-forget,不阻塞初始化。
11700
+ * @internal
11701
+ */
11702
+ start(opts) {
11703
+ this.appId = opts.appId;
11704
+ this.sdkVersion = opts.sdkVersion;
11705
+ void this.calibrate();
11706
+ if (this.timer === null) {
11707
+ this.timer = setInterval(() => void this.calibrate(), RECALIBRATE_INTERVAL_MS);
11708
+ }
11709
+ if (this.visibilityHandler === null && typeof document !== "undefined") {
11710
+ this.visibilityHandler = () => {
11711
+ if (document.visibilityState === "visible") {
11712
+ void this.calibrate();
11713
+ }
11714
+ };
11715
+ document.addEventListener("visibilitychange", this.visibilityHandler);
11716
+ }
11717
+ }
11718
+ /**
11719
+ * 校准一次:多采样取最小 RTT 的样本,同时刷新本地/后台基准与单调零点。
11720
+ * - 拿到后台:serverBase 更新为估算值。
11721
+ * - 拿不到后台:serverBase 置空(后台时间戳留空),localBase 照常更新为当前墙钟。
11722
+ * 已校准过后若本次全失败:保留旧基准、跳过(不把好基准换成更差的)。
11723
+ * @internal
11724
+ */
11725
+ async calibrate() {
11726
+ if (this.syncing) return;
11727
+ this.syncing = true;
11728
+ try {
11729
+ const samples = [];
11730
+ for (let i2 = 0; i2 < SAMPLE_COUNT; i2++) {
11731
+ const s2 = await this.probe();
11732
+ if (s2) samples.push(s2);
11733
+ }
11734
+ if (samples.length > 0) {
11735
+ const best = samples.reduce((a2, b2) => b2.rttNet < a2.rttNet ? b2 : a2);
11736
+ const firstCalibration = !this.calibrated;
11737
+ this.serverBase = best.serverBase;
11738
+ this.localBase = best.localBase;
11739
+ this.monoAtCalibrate = best.monoAtCalibrate;
11740
+ this.calibrated = true;
11741
+ if (firstCalibration) this.fireReady();
11742
+ logMetric("time_calibrated", "info", {
11743
+ has_server: true,
11744
+ rtt_ms: Math.round(best.rttNet)
11745
+ });
11746
+ return;
11747
+ }
11748
+ if (this.calibrated) {
11749
+ logger.log("[ClockSync] recalibrate got no server time, keeping previous baseline");
11750
+ return;
11751
+ }
11752
+ this.localBase = Date.now();
11753
+ this.monoAtCalibrate = performance.now();
11754
+ this.serverBase = null;
11755
+ this.calibrated = true;
11756
+ this.fireReady();
11757
+ logMetric("time_calibrated", "info", { has_server: false });
11758
+ } finally {
11759
+ this.syncing = false;
11760
+ }
11761
+ }
11762
+ /** 单次采样:请求 bootstrap,返回一组 (rttNet, serverBase, monoAtCalibrate, localBase);失败返回 null。 */
11763
+ async probe() {
11764
+ const controller = new AbortController();
11765
+ const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
11766
+ try {
11767
+ let t1 = 0;
11768
+ let localAtT1 = 0;
11769
+ const t0 = performance.now();
11770
+ const body = await fetchBootstrap(
11771
+ {
11772
+ appId: this.appId,
11773
+ sdkVersion: this.sdkVersion,
11774
+ region: "auto",
11775
+ platform: this.platform
11776
+ },
11777
+ controller.signal,
11778
+ () => {
11779
+ t1 = performance.now();
11780
+ localAtT1 = Date.now();
11781
+ }
11782
+ );
11783
+ const ts2 = body == null ? void 0 : body.time_sync;
11784
+ if (!ts2 || typeof ts2.server_receive_ms !== "number" || typeof ts2.server_send_ms !== "number") {
11785
+ return null;
11786
+ }
11787
+ const rttNet = t1 - t0 - (ts2.server_send_ms - ts2.server_receive_ms);
11788
+ const serverBase = ts2.server_send_ms + rttNet / 2;
11789
+ return {
11790
+ rttNet: Math.max(0, rttNet),
11791
+ serverBase,
11792
+ monoAtCalibrate: t1,
11793
+ localBase: localAtT1
11794
+ };
11795
+ } catch {
11796
+ return null;
11797
+ } finally {
11798
+ clearTimeout(timer);
11799
+ }
11800
+ }
11801
+ /** 清理定时器与监听器。 */
11802
+ cleanup() {
11803
+ if (this.timer !== null) {
11804
+ clearInterval(this.timer);
11805
+ this.timer = null;
11806
+ }
11807
+ if (this.visibilityHandler !== null && typeof document !== "undefined") {
11808
+ document.removeEventListener("visibilitychange", this.visibilityHandler);
11809
+ this.visibilityHandler = null;
11810
+ }
11811
+ }
11812
+ }
11813
+ const clockSync = new ClockSync();
11814
+ const FILTERED_HOSTNAMES = ["localhost", "127.0.0.1", "0.0.0.0"];
11815
+ function shouldFilterHostname() {
11816
+ if (typeof window === "undefined") {
11817
+ return false;
11818
+ }
11819
+ const hostname = window.location.hostname;
11820
+ return FILTERED_HOSTNAMES.includes(hostname);
11821
+ }
11822
+ const MARK_KEY = "__mark";
11823
+ function monoTimestamp() {
11824
+ return { [MARK_KEY]: "mono", value: performance.now() };
11825
+ }
11826
+ function isMark(v2) {
11827
+ return typeof v2 === "object" && v2 !== null && MARK_KEY in v2;
11828
+ }
11829
+ function resolveMark(mark) {
11830
+ switch (mark[MARK_KEY]) {
11831
+ case "mono":
11832
+ return clockSync.resolveMono(mark.value);
11833
+ }
11834
+ }
11835
+ function resolveMarks(contents) {
11836
+ return resolveValue(contents);
11837
+ }
11838
+ function resolveValue(value) {
11839
+ if (isMark(value)) return resolveMark(value);
11840
+ if (Array.isArray(value)) {
11841
+ let out = null;
11842
+ for (let i2 = 0; i2 < value.length; i2++) {
11843
+ const resolved = resolveValue(value[i2]);
11844
+ if (resolved !== value[i2]) {
11845
+ if (!out) out = [...value];
11846
+ out[i2] = resolved;
11847
+ }
11848
+ }
11849
+ return out ?? value;
11850
+ }
11851
+ if (typeof value === "object" && value !== null) {
11852
+ const obj = value;
11853
+ let out = null;
11854
+ for (const [k2, v2] of Object.entries(obj)) {
11855
+ const resolved = resolveValue(v2);
11856
+ if (resolved !== v2) {
11857
+ if (!out) out = { ...obj };
11858
+ out[k2] = resolved;
11859
+ }
11860
+ }
11861
+ return out ?? value;
11862
+ }
11863
+ return value;
11864
+ }
11589
11865
  const OTEL_LOGGER_NAME = "spatius-avatarkit";
11590
11866
  let sdkVersion$1 = "1.0.0";
11591
11867
  let isInitialized$1 = false;
11592
11868
  let loggerProvider = null;
11593
11869
  const eventQueue$1 = [];
11870
+ const preCalibrationQueue = [];
11594
11871
  function buildBasicAuthHeader() {
11595
11872
  const credentials = `${OTEL_USERNAME}:${OTEL_PASSWORD}`;
11596
11873
  return `Basic ${btoa(credentials)}`;
@@ -11644,6 +11921,7 @@ function initializeOtel(version, resourceAttrs) {
11644
11921
  isInitialized$1 = true;
11645
11922
  logger.log(`[OTel] Initialized successfully - endpoint: ${OTEL_LOGS_ENDPOINT}, stream: ${OTEL_STREAM_NAME}`);
11646
11923
  flushEventQueue$1();
11924
+ clockSync.onReady(() => flushPreCalibrationQueue());
11647
11925
  replayPersistedEvents().catch(() => {
11648
11926
  });
11649
11927
  } catch (error) {
@@ -11704,7 +11982,27 @@ function flushEventQueue$1() {
11704
11982
  eventQueue$1.length = 0;
11705
11983
  }
11706
11984
  function trackEventOtel(event, level = "info", contents = {}) {
11707
- const timestamp = Date.now();
11985
+ if (shouldFilterHostname()) return;
11986
+ if (!clockSync.isReady()) {
11987
+ preCalibrationQueue.push({ event, level, contents, enqueueMono: performance.now() });
11988
+ return;
11989
+ }
11990
+ emitOtelEvent(event, level, contents, performance.now());
11991
+ }
11992
+ function flushPreCalibrationQueue() {
11993
+ if (preCalibrationQueue.length === 0) return;
11994
+ const pending = preCalibrationQueue.splice(0, preCalibrationQueue.length);
11995
+ for (const e2 of pending) {
11996
+ emitOtelEvent(e2.event, e2.level, e2.contents, e2.enqueueMono);
11997
+ }
11998
+ }
11999
+ function emitOtelEvent(event, level, contents, atMono) {
12000
+ contents = resolveMarks(contents);
12001
+ const timestamp = clockSync.localAt(atMono);
12002
+ const serverTs = clockSync.serverAt(atMono);
12003
+ if (serverTs !== null) {
12004
+ contents = { ...contents, server_timestamp: serverTs };
12005
+ }
11708
12006
  eventStore.add({ event, level, contents, timestamp }).then((id) => {
11709
12007
  const contentsWithIndex = { ...contents, _index: id };
11710
12008
  if (!loggerProvider) return;
@@ -11807,6 +12105,15 @@ function cleanupOtel() {
11807
12105
  loggerProvider = null;
11808
12106
  }
11809
12107
  }
12108
+ function timestampFields(enqueueMono) {
12109
+ const local = enqueueMono === void 0 ? clockSync.localNow() : clockSync.localAt(enqueueMono);
12110
+ const server = enqueueMono === void 0 ? clockSync.serverNow() : clockSync.serverAt(enqueueMono);
12111
+ const fields = { timestamp: local };
12112
+ if (server !== null) {
12113
+ fields.server_timestamp = server;
12114
+ }
12115
+ return fields;
12116
+ }
11810
12117
  let sdkVersion = "1.0.0";
11811
12118
  let isInitialized = false;
11812
12119
  let commonRegion = "";
@@ -11817,14 +12124,6 @@ function isPostHogEnabled() {
11817
12124
  const SDK_POSTHOG_INSTANCE_NAME = "spatius-posthog";
11818
12125
  let sdkPosthogInstance = null;
11819
12126
  const eventQueue = [];
11820
- const FILTERED_HOSTNAMES = ["localhost", "127.0.0.1", "0.0.0.0"];
11821
- function shouldFilterHostname() {
11822
- if (typeof window === "undefined") {
11823
- return false;
11824
- }
11825
- const hostname = window.location.hostname;
11826
- return FILTERED_HOSTNAMES.includes(hostname);
11827
- }
11828
12127
  function getCommonFields() {
11829
12128
  const logContext = idManager.getLogContext();
11830
12129
  return {
@@ -11885,6 +12184,7 @@ function initializePostHog(version, commonFields) {
11885
12184
  }
11886
12185
  posthogInstance.setPersonPropertiesForFlags(userProperties);
11887
12186
  flushEventQueue();
12187
+ clockSync.onReady(() => flushEventQueue());
11888
12188
  }
11889
12189
  }, SDK_POSTHOG_INSTANCE_NAME);
11890
12190
  } catch (error) {
@@ -11948,20 +12248,20 @@ function updatePostHogPersonPropertiesForFlags() {
11948
12248
  instance.setPersonPropertiesForFlags(commonFields);
11949
12249
  }
11950
12250
  function flushEventQueue() {
11951
- if (!sdkPosthogInstance || eventQueue.length === 0) {
12251
+ if (!sdkPosthogInstance || !clockSync.isReady() || eventQueue.length === 0) {
11952
12252
  return;
11953
12253
  }
11954
12254
  logger.log(`[PostHog] Flushing ${eventQueue.length} queued events`);
11955
12255
  setTimeout(() => {
11956
- for (const { event, level, contents } of eventQueue) {
12256
+ for (const { event, level, contents, enqueueMono } of eventQueue) {
11957
12257
  try {
11958
12258
  const commonFields = getCommonFields();
11959
12259
  const properties = {
11960
12260
  ...commonFields,
11961
12261
  level,
11962
12262
  service_module: "sdk",
11963
- timestamp: Date.now(),
11964
- ...contents
12263
+ ...timestampFields(enqueueMono),
12264
+ ...resolveMarks(contents)
11965
12265
  };
11966
12266
  sdkPosthogInstance.capture(event, properties);
11967
12267
  } catch (error) {
@@ -11977,8 +12277,8 @@ function trackEvent(event, level = "info", contents = {}) {
11977
12277
  return;
11978
12278
  }
11979
12279
  const instance = getSdkPosthogInstance();
11980
- if (!instance) {
11981
- eventQueue.push({ event, level, contents });
12280
+ if (!instance || !clockSync.isReady()) {
12281
+ eventQueue.push({ event, level, contents, enqueueMono: performance.now() });
11982
12282
  return;
11983
12283
  }
11984
12284
  sdkPosthogInstance = instance;
@@ -11988,8 +12288,8 @@ function trackEvent(event, level = "info", contents = {}) {
11988
12288
  ...commonFields,
11989
12289
  level,
11990
12290
  service_module: "sdk",
11991
- timestamp: Date.now(),
11992
- ...contents
12291
+ ...timestampFields(),
12292
+ ...resolveMarks(contents)
11993
12293
  };
11994
12294
  sdkPosthogInstance.capture(event, properties);
11995
12295
  } catch (error) {
@@ -12021,28 +12321,39 @@ function cleanupPostHog() {
12021
12321
  sdkPosthogInstance = null;
12022
12322
  }
12023
12323
  }
12024
- function logEvent(event, level = "info", contents = {}) {
12324
+ const TELEMETRY_LOG_METHOD = {
12325
+ debug: "log",
12326
+ info: "log",
12327
+ warning: "warn",
12328
+ error: "error"
12329
+ };
12330
+ function buildTelemetryContext(event, level, contents) {
12025
12331
  const sessionToken = idManager.getSessionToken() ?? "";
12026
12332
  const context2 = {
12027
12333
  session_token_suffix: sessionToken ? sessionToken.slice(-8) : "",
12028
12334
  ...contents
12029
12335
  };
12030
- const logLevels = {
12031
- debug: "log",
12032
- info: "log",
12033
- warning: "warn",
12034
- error: "error"
12035
- };
12036
- const logMethod = logLevels[level] ?? "log";
12336
+ const logMethod = TELEMETRY_LOG_METHOD[level] ?? "log";
12037
12337
  const propsDescription = Object.entries(contents).map(([k2, v2]) => `${k2}=${v2}`).join(", ");
12038
12338
  logger[logMethod](`[Telemetry] ${event} [${propsDescription}]`);
12039
- trackEvent(event, level, context2);
12339
+ return context2;
12340
+ }
12341
+ function emitToOtel(event, level, context2) {
12040
12342
  try {
12041
12343
  trackEventOtel(event, level, context2);
12042
12344
  } catch (error) {
12043
12345
  logger.warn("[OTel] Dual-send failed:", error instanceof Error ? error.message : String(error));
12044
12346
  }
12045
12347
  }
12348
+ function logEvent(event, level = "info", contents = {}) {
12349
+ const context2 = buildTelemetryContext(event, level, contents);
12350
+ trackEvent(event, level, context2);
12351
+ emitToOtel(event, level, context2);
12352
+ }
12353
+ function logMetric(event, level = "info", contents = {}) {
12354
+ const context2 = buildTelemetryContext(event, level, contents);
12355
+ emitToOtel(event, level, context2);
12356
+ }
12046
12357
  const _LogSink = class _LogSink {
12047
12358
  constructor() {
12048
12359
  __publicField(this, "db", null);
@@ -12544,6 +12855,8 @@ const _AnimationPlayer = class _AnimationPlayer {
12544
12855
  __publicField(this, "_isPlaying", false);
12545
12856
  __publicField(this, "fps", APP_CONFIG.animation.fps);
12546
12857
  __publicField(this, "onEndedCallback");
12858
+ // 音频缓冲卡顿回调(透传给 streamingPlayer;控制器注册,用于并入 playback_quality)
12859
+ __publicField(this, "onAudioStallCallback");
12547
12860
  __publicField(this, "useStreaming", false);
12548
12861
  }
12549
12862
  /**
@@ -12627,7 +12940,7 @@ const _AnimationPlayer = class _AnimationPlayer {
12627
12940
  if (this.streamingPlayer) {
12628
12941
  return;
12629
12942
  }
12630
- const { StreamingAudioPlayer } = await import("./StreamingAudioPlayer-DmOtqI_a.js");
12943
+ const { StreamingAudioPlayer } = await import("./StreamingAudioPlayer-2bLnaLam.js");
12631
12944
  const { AvatarSDK: AvatarSDK2 } = await Promise.resolve().then(() => AvatarSDK$1);
12632
12945
  const audioFormat = AvatarSDK2.getAudioFormat();
12633
12946
  this.streamingPlayer = new StreamingAudioPlayer({
@@ -12666,6 +12979,22 @@ const _AnimationPlayer = class _AnimationPlayer {
12666
12979
  this._isPlaying = false;
12667
12980
  (_a = this.onEndedCallback) == null ? void 0 : _a.call(this);
12668
12981
  });
12982
+ this.streamingPlayer.onAudioStall((stalled) => {
12983
+ var _a;
12984
+ (_a = this.onAudioStallCallback) == null ? void 0 : _a.call(this, stalled);
12985
+ });
12986
+ }
12987
+ /**
12988
+ * 注册音频缓冲卡顿回调,透传给底层 streamingPlayer(若已就绪则立即接上)。
12989
+ * @internal
12990
+ */
12991
+ onAudioStall(callback) {
12992
+ var _a;
12993
+ this.onAudioStallCallback = callback;
12994
+ (_a = this.streamingPlayer) == null ? void 0 : _a.onAudioStall((stalled) => {
12995
+ var _a2;
12996
+ (_a2 = this.onAudioStallCallback) == null ? void 0 : _a2.call(this, stalled);
12997
+ });
12669
12998
  }
12670
12999
  setupEventListeners() {
12671
13000
  if (!this.audio)
@@ -12885,6 +13214,38 @@ class AvatarViewRegistry {
12885
13214
  }
12886
13215
  }
12887
13216
  const avatarViewRegistry = new AvatarViewRegistry();
13217
+ const RESOLVE_TIMEOUT_MS = 5e3;
13218
+ async function resolveRegion(opts) {
13219
+ var _a;
13220
+ const { appId, sdkVersion: sdkVersion2, requestedRegion } = opts;
13221
+ if (requestedRegion !== DEFAULT_REGION_REQUEST) {
13222
+ return requestedRegion;
13223
+ }
13224
+ const controller = new AbortController();
13225
+ const timer = setTimeout(() => controller.abort(), RESOLVE_TIMEOUT_MS);
13226
+ try {
13227
+ const res = await fetchBootstrap(
13228
+ { appId, sdkVersion: sdkVersion2, region: "auto" },
13229
+ controller.signal
13230
+ );
13231
+ const current = (_a = res.region) == null ? void 0 : _a.current;
13232
+ if (typeof current === "string" && current.length > 0) {
13233
+ logger.log(`[RegionResolver] auto → ${current}`);
13234
+ return current;
13235
+ }
13236
+ throw new Error("bootstrap response missing region.current");
13237
+ } catch (error) {
13238
+ const message = error instanceof Error ? error.message : String(error);
13239
+ logger.warn(`[RegionResolver] auto resolve failed, falling back to ${DEFAULT_REGION}: ${message}`);
13240
+ logEvent("region_resolve_failed", "error", {
13241
+ fallback_region: DEFAULT_REGION,
13242
+ reason: message
13243
+ });
13244
+ return DEFAULT_REGION;
13245
+ } finally {
13246
+ clearTimeout(timer);
13247
+ }
13248
+ }
12888
13249
  class AvatarCoreMemoryManager {
12889
13250
  constructor(wasmModule) {
12890
13251
  __publicField(this, "module");
@@ -14322,17 +14683,23 @@ class AvatarSDK {
14322
14683
  }
14323
14684
  static async _initializeInternal(appId, configuration) {
14324
14685
  try {
14325
- logger.log(`[AvatarSDK] Initializing with appId: ${appId}, region: ${configuration.region || DEFAULT_REGION}`);
14686
+ const requestedRegion = configuration.region || DEFAULT_REGION_REQUEST;
14687
+ logger.log(`[AvatarSDK] Initializing with appId: ${appId}, region: ${requestedRegion}`);
14326
14688
  resetDeprecationWarnings();
14327
- this._configuration = configuration;
14328
14689
  setLogLevel(configuration.logLevel ?? LogLevel.off);
14329
14690
  idManager.setAppId(appId);
14330
14691
  logger.log(`[AvatarSDK] Client ID: ${idManager.getClientId()}`);
14331
- const region = configuration.region || DEFAULT_REGION;
14332
14692
  const dsm = configuration.drivingServiceMode || DrivingServiceMode.direct;
14333
14693
  const appIdValue = idManager.getAppId() || "";
14694
+ const region = await resolveRegion({
14695
+ appId: appIdValue,
14696
+ sdkVersion: this._version,
14697
+ requestedRegion
14698
+ });
14699
+ this._configuration = { ...configuration, region };
14334
14700
  initializePostHog(this._version, { region, dsm });
14335
14701
  initializeOtel(this._version, { region, dsm, appId: appIdValue });
14702
+ clockSync.start({ appId: appIdValue, sdkVersion: this._version });
14336
14703
  await this.initializeWASMModule();
14337
14704
  await this.initializeTemplateResources();
14338
14705
  this._initializationState = "initialized";
@@ -14594,6 +14961,7 @@ class AvatarSDK {
14594
14961
  idManager.clear();
14595
14962
  cleanupPostHog();
14596
14963
  cleanupOtel();
14964
+ clockSync.cleanup();
14597
14965
  logger.log("[AvatarSDK] Cleanup completed");
14598
14966
  } catch (error) {
14599
14967
  logger.error("Failed to cleanup AvatarSDK:", error instanceof Error ? error.message : String(error));
@@ -14712,7 +15080,7 @@ class AvatarSDK {
14712
15080
  __publicField(AvatarSDK, "_initializationState", "uninitialized");
14713
15081
  __publicField(AvatarSDK, "_initializingPromise", null);
14714
15082
  __publicField(AvatarSDK, "_configuration", null);
14715
- __publicField(AvatarSDK, "_version", "1.3.0");
15083
+ __publicField(AvatarSDK, "_version", "1.3.1-beta.2");
14716
15084
  __publicField(AvatarSDK, "_avatarCore", null);
14717
15085
  __publicField(AvatarSDK, "_cachedDeviceScore", null);
14718
15086
  __publicField(AvatarSDK, "_rendererBackend", null);
@@ -15338,14 +15706,14 @@ class NetworkLayer {
15338
15706
  }
15339
15707
  const metrics = this.audioMetrics;
15340
15708
  if (!metrics.taps.has(0)) {
15341
- metrics.taps.set(0, Date.now());
15709
+ metrics.taps.set(0, monoTimestamp());
15342
15710
  }
15343
15711
  metrics.accumulatedBytes += audioData.byteLength;
15344
15712
  const currentDuration = metrics.accumulatedBytes / this.getAudioBytesPerSecond();
15345
15713
  const maxTapKey = metrics.taps.size > 0 ? Math.max(...metrics.taps.keys()) : 0;
15346
15714
  let s2 = maxTapKey + 1;
15347
15715
  while (currentDuration >= s2) {
15348
- metrics.taps.set(s2, Date.now());
15716
+ metrics.taps.set(s2, monoTimestamp());
15349
15717
  s2++;
15350
15718
  }
15351
15719
  if (audioData.byteLength === 0 && !isLast) {
@@ -15353,7 +15721,6 @@ class NetworkLayer {
15353
15721
  }
15354
15722
  if (this.isFallbackMode) {
15355
15723
  if (this.currentConversationId) {
15356
- logger.warn("[NetworkLayer] Fallback mode: skipping WebSocket send, triggering fallback");
15357
15724
  this.dataController.yieldKeyframes([], this.currentConversationId);
15358
15725
  }
15359
15726
  return;
@@ -15502,7 +15869,7 @@ class NetworkLayer {
15502
15869
  if (!this.dataController.getIsPlaying()) {
15503
15870
  this.dataController.startStreamingPlayback();
15504
15871
  }
15505
- this.audioMetrics.anims.set(this.audioMetrics.animGroupCount, Date.now());
15872
+ this.audioMetrics.anims.set(this.audioMetrics.animGroupCount, monoTimestamp());
15506
15873
  this.audioMetrics.animGroupCount++;
15507
15874
  } else {
15508
15875
  logger.warn(`[NetworkLayer] Animation message has no animation data - conversationId: ${conversationId}`);
@@ -15581,26 +15948,24 @@ class NetworkLayer {
15581
15948
  this.audioMetrics = this.createAudioMetrics();
15582
15949
  }
15583
15950
  /**
15584
- * 上报 driving_service_latency 事件
15585
- * 整轮播放结束(进入 idle)时由 AvatarController 调用,覆盖自然结束与中途打断两条路径
15951
+ * 取本轮端到端延迟打点(tap_N=第N秒音频送入时刻,anim_N=第N组动画到达时刻)。
15952
+ * 整轮播放结束(进入 idle)时由 AvatarController 读取,并入 playback_quality 的
15953
+ * latency 成员统一上报(不再单发 driving_service_latency)。
15954
+ * 无打点数据(如未走 direct 驱动)时返回 null,playback_quality 不带 latency。
15586
15955
  * @internal
15587
15956
  */
15588
- reportDrivingServiceLatency() {
15589
- const conversationId = this.currentConversationId;
15590
- if (!conversationId) return;
15957
+ getDrivingServiceLatency() {
15958
+ if (!this.currentConversationId) return null;
15591
15959
  const metrics = this.audioMetrics;
15592
- if (metrics.taps.size === 0 || metrics.anims.size === 0) return;
15593
- const props = {
15594
- conversation_id: conversationId,
15595
- dsm: "direct"
15596
- };
15960
+ if (metrics.taps.size === 0 || metrics.anims.size === 0) return null;
15961
+ const props = {};
15597
15962
  for (const [n2, t2] of metrics.taps) {
15598
15963
  props[`tap_${n2}`] = t2;
15599
15964
  }
15600
15965
  for (const [n2, t2] of metrics.anims) {
15601
15966
  props[`anim_${n2}`] = t2;
15602
15967
  }
15603
- logEvent("driving_service_latency", "info", props);
15968
+ return props;
15604
15969
  }
15605
15970
  /**
15606
15971
  * Start driving service heartbeat check (check connection status every 2 minutes)
@@ -15897,8 +16262,23 @@ class AvatarController {
15897
16262
  __publicField(this, "isFallbackMode", false);
15898
16263
  // 降级模式标志
15899
16264
  // ========== Frame Starvation Tracking ==========
16265
+ // enterMono:进入卡顿那刻的单调读数(performance.now()),恢复时用来算 durationMs。
16266
+ // durationMs:这次卡顿持续多久(ms),帧恢复时回填;卡到播放结束都没恢复则为 null。
16267
+ // startTs:卡顿开始的时间线时刻(clockSync.timelineNow(),优先后台、退本地,与 tap/anim 同轴)。
16268
+ // framesAtEnter / framesAtRecover:卡顿开始/恢复时本轮已收到的服务端动画帧数
16269
+ // (差值 = 卡顿期间到帧量,区分"服务端没发"与"发了但不够消费")。未恢复则 framesAtRecover=null。
15900
16270
  __publicField(this, "frameStarvationEvents", []);
15901
16271
  __publicField(this, "isFrameStarved", false);
16272
+ // ========== Audio Buffer Stall Tracking ==========
16273
+ // 音频缓冲卡顿(缓冲耗尽自动暂停、有数据自动恢复),与帧饥饿对称记录。字段同义。
16274
+ __publicField(this, "audioStallEvents", []);
16275
+ __publicField(this, "isAudioBufferStalled", false);
16276
+ // 本轮播放的结束原因(供 playback_quality 上报):completed=正常播完,interrupted=中途打断。
16277
+ __publicField(this, "playbackEndReason", "completed");
16278
+ // 本轮已接收的音频总字节数(两模式统一累计)。总时长 = /audioBytesPerSecond。每轮重置。
16279
+ __publicField(this, "receivedAudioBytes", 0);
16280
+ // 本轮已接收的服务端下发动画帧总数(不含本地起播过渡帧)。每轮重置。
16281
+ __publicField(this, "receivedAnimationFrames", 0);
15902
16282
  /**
15903
16283
  * Whether this round's final animation batch (ServerResponseAnimation.end) has arrived.
15904
16284
  * Frame starvation is only possible BEFORE this — once all frames are in, any remaining
@@ -16079,42 +16459,101 @@ class AvatarController {
16079
16459
  /**
16080
16460
  * Notify conversation state change and handle telemetry
16081
16461
  * @internal
16082
- * @param isFallback - true when entering fallback mode (suppresses playback stats)
16083
16462
  */
16084
- notifyConversationState(avatarState, isFallback = false) {
16085
- var _a, _b;
16463
+ notifyConversationState(avatarState) {
16464
+ var _a;
16086
16465
  switch (avatarState) {
16087
16466
  case AvatarState.playing:
16088
16467
  this.frameRateMonitor.resetPlaybackStats();
16089
- this.shouldReportPlaybackStats = !isFallback;
16468
+ this.shouldReportPlaybackStats = true;
16090
16469
  break;
16091
16470
  case AvatarState.paused:
16092
16471
  this.shouldReportPlaybackStats = false;
16093
16472
  break;
16094
16473
  case AvatarState.idle:
16095
- (_a = this.networkLayer) == null ? void 0 : _a.reportDrivingServiceLatency();
16096
16474
  if (this.shouldReportPlaybackStats) {
16097
- const stats = this.frameRateMonitor.getPlaybackStats();
16098
- const bypassSampling = typeof window !== "undefined" && new URLSearchParams(window.location.search).get("integration") === "1";
16099
- if (stats.durationMs >= 2e3 && stats.frameCount > 0 && (bypassSampling || Math.random() < 0.3)) {
16100
- const props = {
16101
- avg_fps: Number(stats.avgFps.toFixed(1)),
16102
- frame_count: stats.frameCount,
16103
- duration_ms: stats.durationMs,
16104
- jank_ratio: Number(stats.jankRatio.toFixed(2)),
16105
- fps_cv: Number(stats.fpsCv.toFixed(3)),
16106
- ...this._getDeviceScoreProps()
16107
- };
16108
- if (AvatarSDK.getRendererBackend()) {
16109
- props.renderer_backend = AvatarSDK.getRendererBackend();
16110
- }
16111
- logEvent("avatar_playback_fps", "info", props);
16112
- }
16475
+ this.reportPlaybackQuality();
16113
16476
  }
16114
16477
  this.shouldReportPlaybackStats = false;
16115
16478
  break;
16116
16479
  }
16117
- (_b = this.onConversationState) == null ? void 0 : _b.call(this, this.mapToConversationState(avatarState));
16480
+ (_a = this.onConversationState) == null ? void 0 : _a.call(this, this.mapToConversationState(avatarState));
16481
+ }
16482
+ /**
16483
+ * 上报本轮播放质量(playback_quality)。每轮播放结束都发一次(不采样、不设时长门槛),
16484
+ * 把帧率与卡顿两块合并为嵌套成员。end_reason 区分完整播完 / 中途打断。
16485
+ * 有卡顿时用 warning,否则 info(保留卡顿的告警语义)。
16486
+ * @internal
16487
+ */
16488
+ reportPlaybackQuality() {
16489
+ var _a, _b;
16490
+ const stats = this.frameRateMonitor.getPlaybackStats();
16491
+ const settled = this.frameStarvationEvents.map((e2) => e2.durationMs).filter((d2) => d2 !== null);
16492
+ const totalStallMs = settled.reduce((a2, b2) => a2 + b2, 0);
16493
+ const stall = {
16494
+ count: this.frameStarvationEvents.length,
16495
+ max_ms: settled.length > 0 ? Math.max(...settled) : 0,
16496
+ total_ms: totalStallMs,
16497
+ // 卡顿率 = 卡顿总时长 / 已播放时长(fps.duration_ms;打断轮即播到打断那刻,
16498
+ // 上限为音频时长)。播放时长为 0 时记 0。
16499
+ ratio: stats.durationMs > 0 ? Number((totalStallMs / stats.durationMs).toFixed(4)) : 0,
16500
+ req_end: this.frameStarvationEvents.some((e2) => e2.reqEnd),
16501
+ // 每次卡顿明细:开始时间戳(timelineNow,与 tap/anim 同轴)/ 卡顿时的音频时刻 /
16502
+ // 等了多久(null=卡到播放结束没恢复)/ 卡顿开始与恢复时已收到的动画帧数
16503
+ // (frames_at_recover - frames_at_enter = 卡顿期间到帧量)。
16504
+ events: this.frameStarvationEvents.map((e2) => ({
16505
+ start_ts: Math.round(e2.startTs),
16506
+ audio_time: Number(e2.audioTime.toFixed(3)),
16507
+ duration_ms: e2.durationMs,
16508
+ frames_at_enter: e2.framesAtEnter,
16509
+ frames_at_recover: e2.framesAtRecover
16510
+ }))
16511
+ };
16512
+ const audioSettled = this.audioStallEvents.map((e2) => e2.durationMs).filter((d2) => d2 !== null);
16513
+ const audioStall = {
16514
+ count: this.audioStallEvents.length,
16515
+ max_ms: audioSettled.length > 0 ? Math.max(...audioSettled) : 0,
16516
+ total_ms: audioSettled.reduce((a2, b2) => a2 + b2, 0),
16517
+ events: this.audioStallEvents.map((e2) => ({
16518
+ start_ts: Math.round(e2.startTs),
16519
+ duration_ms: e2.durationMs,
16520
+ frames_at_enter: e2.framesAtEnter,
16521
+ frames_at_recover: e2.framesAtRecover
16522
+ }))
16523
+ };
16524
+ const fps = {
16525
+ avg_fps: Number(stats.avgFps.toFixed(1)),
16526
+ frame_count: stats.frameCount,
16527
+ duration_ms: stats.durationMs,
16528
+ jank_ratio: Number(stats.jankRatio.toFixed(2)),
16529
+ fps_cv: Number(stats.fpsCv.toFixed(3)),
16530
+ ...this._getDeviceScoreProps()
16531
+ };
16532
+ if (AvatarSDK.getRendererBackend()) {
16533
+ fps.renderer_backend = AvatarSDK.getRendererBackend();
16534
+ }
16535
+ const latency = ((_a = this.networkLayer) == null ? void 0 : _a.getDrivingServiceLatency()) ?? null;
16536
+ logMetric("playback_quality", stall.count > 0 || audioStall.count > 0 ? "warning" : "info", {
16537
+ avatar_id: this.avatar.id,
16538
+ conversation_id: ((_b = this.networkLayer) == null ? void 0 : _b.getCurrentConversationId()) || void 0,
16539
+ end_reason: this.playbackEndReason,
16540
+ // 是否降级模式(audio-only 无动画帧):为 true 时 fps/animation_frame_count 空是预期
16541
+ is_fallback: this.isFallbackMode,
16542
+ // 本轮接收情况:总音频时长(ms) / 是否收到最后音频(end=true) /
16543
+ // 服务端下发总动画帧数(不含过渡帧) / 是否收到最后动画包(end=true)
16544
+ audio_duration_ms: Math.round(this.receivedAudioBytes / this.audioBytesPerSecond * 1e3),
16545
+ audio_ended: this.reqEnd,
16546
+ animation_frame_count: this.receivedAnimationFrames,
16547
+ animation_ended: this.animationEnded,
16548
+ fps,
16549
+ stall,
16550
+ audio_stall: audioStall,
16551
+ ...latency ? { latency } : {}
16552
+ });
16553
+ this.frameStarvationEvents = [];
16554
+ this.isFrameStarved = false;
16555
+ this.audioStallEvents = [];
16556
+ this.isAudioBufferStalled = false;
16118
16557
  }
16119
16558
  /**
16120
16559
  * Get animation player instance
@@ -16234,6 +16673,7 @@ class AvatarController {
16234
16673
  if (this.reqEnd && this.isPlaying && networkConversationId) {
16235
16674
  this.interrupt();
16236
16675
  }
16676
+ this.receivedAudioBytes += audioData.byteLength;
16237
16677
  this.addAudioChunkToBuffer(new Uint8Array(audioData), end);
16238
16678
  this.networkLayer.sendAudioData(audioData, end);
16239
16679
  if (end) {
@@ -16333,18 +16773,19 @@ class AvatarController {
16333
16773
  if (isLast) {
16334
16774
  this.reqEnd = true;
16335
16775
  }
16776
+ this.receivedAudioBytes += data.length;
16336
16777
  if (this.playbackMode === DrivingServiceMode.backend) {
16337
16778
  const metrics = this.hostModeMetrics;
16338
16779
  if (metrics.startTimestamp === 0) {
16339
- metrics.startTimestamp = Date.now();
16780
+ metrics.startTimestamp = clockSync.timelineNow();
16340
16781
  }
16341
16782
  metrics.accumulatedBytes += data.length;
16342
16783
  const currentDuration = metrics.accumulatedBytes / this.audioBytesPerSecond;
16343
16784
  if (currentDuration >= 1 && metrics.tap1Timestamp === 0) {
16344
- metrics.tap1Timestamp = Date.now();
16785
+ metrics.tap1Timestamp = clockSync.timelineNow();
16345
16786
  }
16346
16787
  if (currentDuration >= 2 && metrics.tap2Timestamp === 0) {
16347
- metrics.tap2Timestamp = Date.now();
16788
+ metrics.tap2Timestamp = clockSync.timelineNow();
16348
16789
  }
16349
16790
  }
16350
16791
  if (this.isPlaying && ((_b = this.animationPlayer) == null ? void 0 : _b.isStreamingReady())) {
@@ -16461,11 +16902,12 @@ class AvatarController {
16461
16902
  return;
16462
16903
  }
16463
16904
  const flameKeyframes = keyframes;
16905
+ this.receivedAnimationFrames += flameKeyframes.length;
16464
16906
  if (this.currentKeyframes.length === 0) {
16465
16907
  this.currentKeyframes = flameKeyframes;
16466
16908
  if (this.playbackMode === DrivingServiceMode.backend && !this.hostModeMetrics.didRecvFirstFlame) {
16467
16909
  this.hostModeMetrics.didRecvFirstFlame = true;
16468
- this.hostModeMetrics.recvFirstFlameTimestamp = Date.now();
16910
+ this.hostModeMetrics.recvFirstFlameTimestamp = clockSync.timelineNow();
16469
16911
  }
16470
16912
  } else {
16471
16913
  this.currentKeyframes.push(...flameKeyframes);
@@ -16601,6 +17043,8 @@ class AvatarController {
16601
17043
  this.lastSyncLogTime = 0;
16602
17044
  this.lastOutOfBoundsState = false;
16603
17045
  this.animationEnded = false;
17046
+ this.receivedAudioBytes = 0;
17047
+ this.receivedAnimationFrames = 0;
16604
17048
  if (this.isAudioStalledForStarvation) {
16605
17049
  this.isAudioStalledForStarvation = false;
16606
17050
  (_a = this.onPlaybackStall) == null ? void 0 : _a.call(this, false);
@@ -16793,6 +17237,9 @@ class AvatarController {
16793
17237
  await this.animationPlayer.prepareStreamingPlayer(() => {
16794
17238
  this.onPlaybackEnded();
16795
17239
  });
17240
+ this.animationPlayer.onAudioStall((stalled) => {
17241
+ this.trackAudioBufferStall(stalled);
17242
+ });
16796
17243
  if (!this.isFallbackMode) {
16797
17244
  this.emit("startRendering");
16798
17245
  }
@@ -16810,7 +17257,7 @@ class AvatarController {
16810
17257
  this.pendingAudioChunks = [];
16811
17258
  this.isPlaying = true;
16812
17259
  this.currentState = AvatarState.playing;
16813
- this.notifyConversationState(AvatarState.playing, this.isFallbackMode);
17260
+ this.notifyConversationState(AvatarState.playing);
16814
17261
  this.isStartingPlayback = false;
16815
17262
  logEvent("playback_started", "info", {
16816
17263
  avatar_id: this.avatar.id,
@@ -16953,13 +17400,28 @@ class AvatarController {
16953
17400
  const totalKeyframes = this.currentKeyframes.length + this.keyframesOffset;
16954
17401
  const frameDiff = frameIndex - totalKeyframes;
16955
17402
  if (!(this.reqEnd && frameDiff <= 1)) {
16956
- this.frameStarvationEvents.push({ audioTime, reqEnd: this.reqEnd });
17403
+ this.frameStarvationEvents.push({
17404
+ audioTime,
17405
+ reqEnd: this.reqEnd,
17406
+ enterMono: performance.now(),
17407
+ durationMs: null,
17408
+ startTs: clockSync.timelineNow(),
17409
+ framesAtEnter: this.receivedAnimationFrames,
17410
+ framesAtRecover: null
17411
+ });
16957
17412
  }
16958
17413
  }
16959
17414
  if (!this.animationEnded) {
16960
17415
  this.pauseAudioForStarvation();
16961
17416
  }
16962
17417
  } else {
17418
+ if (this.isFrameStarved) {
17419
+ const last = this.frameStarvationEvents[this.frameStarvationEvents.length - 1];
17420
+ if (last && last.durationMs === null) {
17421
+ last.durationMs = Math.round(performance.now() - last.enterMono);
17422
+ last.framesAtRecover = this.receivedAnimationFrames;
17423
+ }
17424
+ }
16963
17425
  this.isFrameStarved = false;
16964
17426
  if (isOutOfBounds !== this.lastOutOfBoundsState) {
16965
17427
  this.lastOutOfBoundsState = isOutOfBounds;
@@ -16967,6 +17429,33 @@ class AvatarController {
16967
17429
  this.resumeAudioFromStarvation();
16968
17430
  }
16969
17431
  }
17432
+ /**
17433
+ * 记录音频缓冲卡顿(缓冲耗尽自动暂停 / 有数据自动恢复),并入 playback_quality.audio_stall。
17434
+ * 与 trackFrameStarvation 对称:进入 push 事件、恢复回填时长与动画帧数。
17435
+ * @param stalled true=进入卡顿,false=恢复
17436
+ * @internal
17437
+ */
17438
+ trackAudioBufferStall(stalled) {
17439
+ if (stalled) {
17440
+ if (this.isAudioBufferStalled) return;
17441
+ this.isAudioBufferStalled = true;
17442
+ this.audioStallEvents.push({
17443
+ enterMono: performance.now(),
17444
+ durationMs: null,
17445
+ startTs: clockSync.timelineNow(),
17446
+ framesAtEnter: this.receivedAnimationFrames,
17447
+ framesAtRecover: null
17448
+ });
17449
+ } else {
17450
+ if (!this.isAudioBufferStalled) return;
17451
+ this.isAudioBufferStalled = false;
17452
+ const last = this.audioStallEvents[this.audioStallEvents.length - 1];
17453
+ if (last && last.durationMs === null) {
17454
+ last.durationMs = Math.round(performance.now() - last.enterMono);
17455
+ last.framesAtRecover = this.receivedAnimationFrames;
17456
+ }
17457
+ }
17458
+ }
16970
17459
  /**
16971
17460
  * strictSync: pause audio on frame starvation, waiting for new frames.
16972
17461
  *
@@ -17042,7 +17531,6 @@ class AvatarController {
17042
17531
  if (this.isFallbackMode) {
17043
17532
  return;
17044
17533
  }
17045
- logger.warn("[AvatarController] Enabling fallback mode");
17046
17534
  this.isFallbackMode = true;
17047
17535
  this.resumeAudioFromStarvation();
17048
17536
  logEvent("fallback_mode_entered", "warning", {
@@ -17076,26 +17564,15 @@ class AvatarController {
17076
17564
  * @internal
17077
17565
  */
17078
17566
  onPlaybackEnded() {
17079
- var _a, _b;
17080
- if (this.frameStarvationEvents.length > 0) {
17081
- const hasReqEnd = this.frameStarvationEvents.some((e2) => e2.reqEnd);
17082
- logEvent("frame_starvation", "warning", {
17083
- avatar_id: this.avatar.id,
17084
- conversation_id: ((_a = this.networkLayer) == null ? void 0 : _a.getCurrentConversationId()) || void 0,
17085
- starvation_count: this.frameStarvationEvents.length,
17086
- starvation_times: this.frameStarvationEvents.map((e2) => Number(e2.audioTime.toFixed(3))),
17087
- ...hasReqEnd ? { req_end: true } : {}
17088
- });
17089
- }
17090
- this.frameStarvationEvents = [];
17091
- this.isFrameStarved = false;
17567
+ var _a;
17092
17568
  logEvent("playback_ended", "info", {
17093
17569
  avatar_id: this.avatar.id,
17094
- conversation_id: this.currentConversationId || ((_b = this.networkLayer) == null ? void 0 : _b.getCurrentConversationId()) || void 0,
17570
+ conversation_id: this.currentConversationId || ((_a = this.networkLayer) == null ? void 0 : _a.getCurrentConversationId()) || void 0,
17095
17571
  source: this.isFallbackMode ? "fallback" : "streaming"
17096
17572
  });
17097
17573
  this.isPlaying = false;
17098
17574
  this.currentState = AvatarState.idle;
17575
+ this.playbackEndReason = "completed";
17099
17576
  this.notifyConversationState(AvatarState.idle);
17100
17577
  this.emit("stopRendering");
17101
17578
  this.emit("stopFallback");
@@ -17113,6 +17590,7 @@ class AvatarController {
17113
17590
  this.emit("stopRendering");
17114
17591
  this.isPlaying = false;
17115
17592
  this.currentState = AvatarState.idle;
17593
+ this.playbackEndReason = "interrupted";
17116
17594
  this.notifyConversationState(AvatarState.idle);
17117
17595
  }
17118
17596
  /**
@@ -17490,7 +17968,7 @@ class AvatarDownloader {
17490
17968
  if (cached) {
17491
17969
  const duration = Date.now() - startTime;
17492
17970
  logger.log(`✅ Unified template loaded from cache (${(cached.byteLength / 1024 / 1024).toFixed(1)} MB)`);
17493
- logEvent("template_resources_load_measure", "info", {
17971
+ logMetric("template_resources_load_measure", "info", {
17494
17972
  duration,
17495
17973
  file_count: 1,
17496
17974
  cache_hit: true,
@@ -17519,7 +17997,7 @@ class AvatarDownloader {
17519
17997
  logger.warn(`[loadUnifiedTemplate] Failed to cache:`, err);
17520
17998
  });
17521
17999
  const duration = Date.now() - startTime;
17522
- logEvent("template_resources_load_measure", "info", {
18000
+ logMetric("template_resources_load_measure", "info", {
17523
18001
  duration,
17524
18002
  file_count: 1,
17525
18003
  cache_hit: false,
@@ -17672,7 +18150,7 @@ class AvatarDownloader {
17672
18150
  const totalSize = Object.values(characterData).reduce((sum, buffer) => {
17673
18151
  return sum + (buffer ? buffer.byteLength : 0);
17674
18152
  }, 0);
17675
- logEvent("download_avatar_assets_latency", "info", {
18153
+ logMetric("download_avatar_assets_latency", "info", {
17676
18154
  resolution: "default",
17677
18155
  // 目前写死 default
17678
18156
  avatar_id: characterMeta.characterId ?? "unknown",
@@ -17866,7 +18344,7 @@ class AvatarDownloader {
17866
18344
  );
17867
18345
  }
17868
18346
  const duration = Date.now() - startTime;
17869
- logEvent("fetch_avatar_metadata_latency", "info", {
18347
+ logMetric("fetch_avatar_metadata_latency", "info", {
17870
18348
  avatar_id: characterId,
17871
18349
  duration,
17872
18350
  trace_id: traceId
@@ -18108,7 +18586,7 @@ const _AvatarManager = class _AvatarManager {
18108
18586
  this.avatarCache.set(id, avatar);
18109
18587
  logger.log("[AvatarManager] Avatar loaded successfully");
18110
18588
  const totalDuration = Date.now() - startTime;
18111
- logEvent("fetch_avatar_latency", "info", {
18589
+ logMetric("fetch_avatar_latency", "info", {
18112
18590
  resolution: "default",
18113
18591
  // 目前写死 default
18114
18592
  avatar_id: id,
@@ -20400,6 +20878,11 @@ class AvatarView {
20400
20878
  * @internal
20401
20879
  */
20402
20880
  createCanvas(container) {
20881
+ if (container.querySelector("canvas")) {
20882
+ logger.warn(
20883
+ "[AvatarView] The container already contains a <canvas>. This usually means another AvatarView was created in the same container without disposing the previous one, which will render two avatars side by side. Call dispose() on the previous AvatarView before creating a new one, or use a separate container."
20884
+ );
20885
+ }
20403
20886
  const canvas = document.createElement("canvas");
20404
20887
  const containerWidth = container.offsetWidth || 800;
20405
20888
  const containerHeight = container.offsetHeight || 600;
@@ -21746,12 +22229,13 @@ export {
21746
22229
  AvatarView as g,
21747
22230
  RENDER_QUALITY_PARAMS as h,
21748
22231
  DEFAULT_REGION as i,
21749
- LoadProgress as j,
21750
- AnimationType as k,
22232
+ DEFAULT_REGION_REQUEST as j,
22233
+ LoadProgress as k,
21751
22234
  logger as l,
21752
- ConversationState as m,
21753
- AvatarState as n,
21754
- AvatarError as o,
21755
- ResourceType as p,
21756
- extractResourceUrls as q
22235
+ AnimationType as m,
22236
+ ConversationState as n,
22237
+ AvatarState as o,
22238
+ AvatarError as p,
22239
+ ResourceType as q,
22240
+ extractResourceUrls as r
21757
22241
  };
package/dist/index.js CHANGED
@@ -1,24 +1,25 @@
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-BDFs1WRg.js";
1
+ import { m, b, c, p, f, d, o, g, C, n, i, j, D, E, F, k, L, h, R, q, T, r } from "./index-CPolDcOo.js";
2
2
  export {
3
- k as AnimationType,
3
+ m as AnimationType,
4
4
  b as Avatar,
5
5
  c as AvatarController,
6
- o as AvatarError,
6
+ p as AvatarError,
7
7
  f as AvatarManager,
8
8
  d as AvatarSDK,
9
- n as AvatarState,
9
+ o as AvatarState,
10
10
  g as AvatarView,
11
11
  C as ConnectionState,
12
- m as ConversationState,
12
+ n as ConversationState,
13
13
  i as DEFAULT_REGION,
14
+ j as DEFAULT_REGION_REQUEST,
14
15
  D as DrivingServiceMode,
15
16
  E as ErrorCode,
16
17
  F as FrameStarvationMode,
17
- j as LoadProgress,
18
+ k as LoadProgress,
18
19
  L as LogLevel,
19
20
  h as RENDER_QUALITY_PARAMS,
20
21
  R as RenderQuality,
21
- p as ResourceType,
22
+ q as ResourceType,
22
23
  T as TransitionType,
23
- q as extractResourceUrls
24
+ r as extractResourceUrls
24
25
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spatius/avatarkit",
3
3
  "type": "module",
4
- "version": "1.3.0",
4
+ "version": "1.3.1-beta.2",
5
5
  "packageManager": "pnpm@10.18.2",
6
6
  "description": "AvatarKit SDK - Real-time Avatar Rendering SDK for Web",
7
7
  "author": "AvatarKit Team",
@@ -87,6 +87,7 @@
87
87
  "devDependencies": {
88
88
  "@types/node": "^20.11.30",
89
89
  "@webgpu/types": "^0.1.65",
90
+ "fflate": "0.8.3",
90
91
  "tsx": "^4.20.6",
91
92
  "typescript": "^5.0.0",
92
93
  "vite": "^5.0.0",