@xmov/avatar 2.0.0-alpha.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/README.md +139 -0
  2. package/dist/agent/__tests__/agent.test.d.ts +1 -0
  3. package/dist/agent/audio-debug.d.ts +3 -0
  4. package/dist/agent/audio-uplink.d.ts +62 -0
  5. package/dist/agent/avatar.cjs +2 -0
  6. package/dist/agent/avatar.cjs.map +1 -0
  7. package/dist/agent/avatar.modern.js +2 -0
  8. package/dist/agent/avatar.modern.js.map +1 -0
  9. package/dist/agent/avatar.module.js +2 -0
  10. package/dist/agent/avatar.module.js.map +1 -0
  11. package/dist/agent/avatar.umd.js +2 -0
  12. package/dist/agent/avatar.umd.js.map +1 -0
  13. package/dist/agent/e2e-client.d.ts +32 -0
  14. package/dist/agent/fixed-audio-track.d.ts +48 -0
  15. package/dist/agent/index.cjs +5 -0
  16. package/dist/agent/index.d.ts +104 -0
  17. package/dist/agent/index.umd.js +10 -0
  18. package/dist/agent/microphone.d.ts +49 -0
  19. package/dist/agent/types.d.ts +153 -0
  20. package/dist/baseRender/AudioRenderer.d.ts +46 -0
  21. package/dist/baseRender/AudioWorklet.d.ts +78 -0
  22. package/dist/baseRender/AvatarRenderer.d.ts +226 -0
  23. package/dist/baseRender/MSEAudioPlayer.d.ts +51 -0
  24. package/dist/baseRender/UIRenderer.d.ts +39 -0
  25. package/dist/baseRender/pcm-audio-processor.d.ts +63 -0
  26. package/dist/control/APIForwarder.d.ts +23 -0
  27. package/dist/control/DataCacheQueue.d.ts +69 -0
  28. package/dist/control/EventDispatcher.d.ts +35 -0
  29. package/dist/control/RenderScheduler.d.ts +162 -0
  30. package/dist/control/SaveAndDownload.d.ts +14 -0
  31. package/dist/control/StreamingClient.d.ts +33 -0
  32. package/dist/control/VideoPlayer.d.ts +47 -0
  33. package/dist/control/ttsa.d.ts +111 -0
  34. package/dist/encoding/media-recorder-encoder.d.ts +33 -0
  35. package/dist/encoding/pcm-webm-encoder.d.ts +13 -0
  36. package/dist/encoding/webcodec-opus-encoder.d.ts +41 -0
  37. package/dist/encoding/webm-muxer.d.ts +90 -0
  38. package/dist/index.cjs +2 -0
  39. package/dist/index.cjs.map +1 -0
  40. package/dist/index.d.ts +231 -0
  41. package/dist/index.modern.js +2 -0
  42. package/dist/index.modern.js.map +1 -0
  43. package/dist/index.module.js +2 -0
  44. package/dist/index.module.js.map +1 -0
  45. package/dist/index.umd.js +2 -0
  46. package/dist/index.umd.js.map +1 -0
  47. package/dist/modules/Composition.d.ts +28 -0
  48. package/dist/modules/ResourceManager.d.ts +295 -0
  49. package/dist/modules/TrackRenderer/base-track.d.ts +4 -0
  50. package/dist/modules/TrackRenderer/index.d.ts +12 -0
  51. package/dist/modules/TrackRenderer/render-implements.d.ts +19 -0
  52. package/dist/modules/TrackRenderer/track-pic.d.ts +8 -0
  53. package/dist/modules/TrackRenderer/track-subtitle.d.ts +8 -0
  54. package/dist/modules/cache-manager.d.ts +5 -0
  55. package/dist/modules/decoder.d.ts +84 -0
  56. package/dist/modules/error-handle.d.ts +17 -0
  57. package/dist/modules/network.d.ts +21 -0
  58. package/dist/proto/face_data_pb.d.ts +0 -0
  59. package/dist/types/capability.d.ts +15 -0
  60. package/dist/types/error.d.ts +70 -0
  61. package/dist/types/event.d.ts +25 -0
  62. package/dist/types/frame-data.d.ts +107 -0
  63. package/dist/types/index.d.ts +187 -0
  64. package/dist/types/render.d.ts +18 -0
  65. package/dist/utils/DataInterface.d.ts +113 -0
  66. package/dist/utils/GLDevice.d.ts +46 -0
  67. package/dist/utils/GLPipeline.d.ts +84 -0
  68. package/dist/utils/GLPipelineDebugTools.d.ts +5 -0
  69. package/dist/utils/Math.d.ts +21 -0
  70. package/dist/utils/__tests__/capability-checker.test.d.ts +1 -0
  71. package/dist/utils/audio-session.d.ts +11 -0
  72. package/dist/utils/audio.d.ts +1 -0
  73. package/dist/utils/capability-checker.d.ts +23 -0
  74. package/dist/utils/encodeToken.d.ts +7 -0
  75. package/dist/utils/face.d.ts +1 -0
  76. package/dist/utils/float32-decoder.d.ts +26 -0
  77. package/dist/utils/fpsTracker.d.ts +27 -0
  78. package/dist/utils/index.d.ts +22 -0
  79. package/dist/utils/logger.d.ts +9 -0
  80. package/dist/utils/media-recorder-timestamp.d.ts +15 -0
  81. package/dist/utils/pcmAnalyzer.d.ts +40 -0
  82. package/dist/utils/perfermance.d.ts +15 -0
  83. package/dist/utils/request.d.ts +19 -0
  84. package/dist/utils/requestAnimateFrames.d.ts +31 -0
  85. package/dist/utils/time.d.ts +9 -0
  86. package/dist/view/DebugOverlay.d.ts +54 -0
  87. package/dist/worker/streaming-video.d.ts +1 -0
  88. package/package.json +82 -0
  89. package/src/agent/__tests__/agent.test.ts +3787 -0
  90. package/src/agent/audio-debug.ts +36 -0
  91. package/src/agent/audio-uplink.ts +392 -0
  92. package/src/agent/e2e-client.ts +215 -0
  93. package/src/agent/fixed-audio-track.ts +547 -0
  94. package/src/agent/index.ts +1393 -0
  95. package/src/agent/microphone.ts +534 -0
  96. package/src/agent/types.ts +197 -0
  97. package/src/baseRender/AudioRenderer.ts +317 -0
  98. package/src/baseRender/AudioWorklet.ts +776 -0
  99. package/src/baseRender/AvatarRenderer.ts +1559 -0
  100. package/src/baseRender/MSEAudioPlayer.ts +243 -0
  101. package/src/baseRender/UIRenderer.ts +195 -0
  102. package/src/baseRender/pcm-audio-processor.js +267 -0
  103. package/src/control/APIForwarder.ts +67 -0
  104. package/src/control/DataCacheQueue.ts +372 -0
  105. package/src/control/EventDispatcher.ts +106 -0
  106. package/src/control/RenderScheduler.ts +945 -0
  107. package/src/control/SaveAndDownload.js +77 -0
  108. package/src/control/StreamingClient.ts +138 -0
  109. package/src/control/VideoPlayer.ts +306 -0
  110. package/src/control/ttsa.ts +676 -0
  111. package/src/encoding/media-recorder-encoder.ts +175 -0
  112. package/src/encoding/pcm-webm-encoder.ts +72 -0
  113. package/src/encoding/webcodec-opus-encoder.ts +258 -0
  114. package/src/encoding/webm-muxer.ts +337 -0
  115. package/src/global.d.ts +105 -0
  116. package/src/index.ts +1920 -0
  117. package/src/modules/Composition.ts +79 -0
  118. package/src/modules/ResourceManager.ts +899 -0
  119. package/src/modules/TrackRenderer/base-track.ts +7 -0
  120. package/src/modules/TrackRenderer/index.ts +40 -0
  121. package/src/modules/TrackRenderer/render-implements.ts +269 -0
  122. package/src/modules/TrackRenderer/track-pic.ts +19 -0
  123. package/src/modules/TrackRenderer/track-subtitle.ts +16 -0
  124. package/src/modules/cache-manager.ts +10 -0
  125. package/src/modules/decoder.ts +515 -0
  126. package/src/modules/error-handle.ts +20 -0
  127. package/src/modules/network.ts +71 -0
  128. package/src/proto/face_data.proto +45 -0
  129. package/src/proto/face_data_pb.js +1297 -0
  130. package/src/proto/protobuf.min.js +8 -0
  131. package/src/types/capability.ts +23 -0
  132. package/src/types/error.ts +211 -0
  133. package/src/types/event.ts +38 -0
  134. package/src/types/frame-data.ts +121 -0
  135. package/src/types/index.ts +207 -0
  136. package/src/types/render.ts +18 -0
  137. package/src/utils/DataInterface.ts +746 -0
  138. package/src/utils/GLDevice.ts +286 -0
  139. package/src/utils/GLPipeline.ts +1293 -0
  140. package/src/utils/GLPipelineDebugTools.ts +174 -0
  141. package/src/utils/Math.ts +266 -0
  142. package/src/utils/__tests__/capability-checker.test.ts +159 -0
  143. package/src/utils/audio-session.ts +144 -0
  144. package/src/utils/audio.ts +44 -0
  145. package/src/utils/blueimp-md5.d.ts +3 -0
  146. package/src/utils/capability-checker.ts +379 -0
  147. package/src/utils/encodeToken.ts +100 -0
  148. package/src/utils/face.ts +29 -0
  149. package/src/utils/float32-decoder.js +130 -0
  150. package/src/utils/fpsTracker.ts +100 -0
  151. package/src/utils/index.ts +182 -0
  152. package/src/utils/logger.js +36 -0
  153. package/src/utils/media-recorder-timestamp.ts +79 -0
  154. package/src/utils/pcmAnalyzer.ts +154 -0
  155. package/src/utils/perfermance.ts +141 -0
  156. package/src/utils/request.ts +95 -0
  157. package/src/utils/requestAnimateFrames.ts +195 -0
  158. package/src/utils/time.ts +32 -0
  159. package/src/view/DebugOverlay.ts +404 -0
  160. package/src/worker/streaming-video.ts +4647 -0
package/src/index.ts ADDED
@@ -0,0 +1,1920 @@
1
+ import { AvatarStatus, RenderState, IAvatarOptions, IInitParams, InitModel, Layout, WalkConfig } from "./types/index";
2
+ import { EErrorCode } from "./types/error";
3
+ import { EFrameDataType, IRawFrameData, StateChangeInfo, SYNC_FRAME_DATA } from "./types/frame-data";
4
+ import RenderScheduler from "./control/RenderScheduler";
5
+ import ResourceManager, { ISessionResponse, TDownloadProgress } from "./modules/ResourceManager";
6
+ import WidgetDefaultRender from "./modules/TrackRenderer/render-implements";
7
+ import Ttsa from "./control/ttsa";
8
+ import { DebugOverlay } from "./view/DebugOverlay";
9
+ import NetworkMonitor from './modules/network';
10
+ import Errors from './modules/error-handle';
11
+
12
+ import { performanceConstant } from "./utils/perfermance";
13
+ import './utils/logger.js';
14
+ import { IBRAnimationGeneratorCharInfo_NN, unpackIBRAnimation, formatMJT } from './utils/DataInterface'
15
+ import { getVertices, getWavefrontObjFromVertices, getPCATextures } from './utils/GLPipelineDebugTools'
16
+ import { GLDevice } from './utils/GLDevice'
17
+ import { GLPipeline } from './utils/GLPipeline'
18
+ import { getDeviceInfo, isharmony, isSupportMES, validateScaleWithDecimal } from "./utils";
19
+ import { StreamingClient } from './control/StreamingClient';
20
+ import { VideoPlayer } from './control/VideoPlayer';
21
+ import { EventDispatcher } from './control/EventDispatcher';
22
+ import { APIForwarder } from './control/APIForwarder';
23
+ import { checkCapability } from './utils/capability-checker';
24
+ import { FPSStats } from "./utils/fpsTracker";
25
+ import { PcmWebMEncoder } from './encoding/pcm-webm-encoder';
26
+ import { MediaRecorderEncoder } from './encoding/media-recorder-encoder';
27
+
28
+ type InternalAvatarOptions = IAvatarOptions & {
29
+ sessionRequestData?: Record<string, unknown>
30
+ }
31
+
32
+ export default class XmovAvatar {
33
+ //static STATUS: AvatarStatus | -1 = -1;
34
+
35
+ private options!: IAvatarOptions;
36
+
37
+ private TAG = "[XMOV AVATAR]";
38
+ private el: HTMLElement | null = null;
39
+
40
+ // 实例状态,每个 XmovAvatar 实例都有自己独立的状态
41
+ private status: AvatarStatus | -1 = -1;
42
+
43
+ // 使用 ! 操作符告诉 TypeScript 这些属性会在构造函数中被初始化
44
+ private resourceManager!: ResourceManager;
45
+ private renderScheduler!: RenderScheduler;
46
+ private networkMonitor: NetworkMonitor | null = null;
47
+ private ttsa: Ttsa | null = null;
48
+
49
+ private debugOverlay: DebugOverlay | null = null;
50
+
51
+ private _offlineTimer = -1
52
+ private readonly _offlineInterval = 300
53
+ private _env = "production"
54
+ // 可以与隐身模式做联动,
55
+ private avatarCanvasVisible: boolean = true; // 数字人canvas显隐状态,默认为true
56
+ private pendingInvisibleMode: boolean = false; // 待处理的隐身模式(在init时设置,在start时应用)
57
+ private isInitialized: boolean = false; // 初始化是否完成(progress === 100)
58
+ public onStateChange: (state: string) => void = () => {};
59
+
60
+ public onDownloadProgress: TDownloadProgress | null = null;
61
+
62
+ static IBRAnimationGeneratorCharInfo_NN = IBRAnimationGeneratorCharInfo_NN;
63
+ static unpackIBRAnimation = unpackIBRAnimation;
64
+ static formatMJT = formatMJT;
65
+ static getVertices = getVertices;
66
+ static getPCATextures = getPCATextures;
67
+ static getWavefrontObjFromVertices = getWavefrontObjFromVertices;
68
+ static GLDevice = GLDevice;
69
+ static GLPipeline = GLPipeline;
70
+ static checkCapability = checkCapability;
71
+ private boundVisibilityChange: () => void;
72
+ private replayData: any = null;
73
+ private startConnectTime: number = 0;
74
+ private connectSuccessTime: number = 0;
75
+ private sessionResTime: number = 0;
76
+ private charbinResTime: number = 0;
77
+ private isfirstReceive: boolean = true;
78
+ private enableClientInterrupt: boolean = false; // 是否开启客户端打断
79
+ private retryCount = 1;
80
+ private retryTimer = -1;
81
+ private maxRetryCount = 9;
82
+ private retryRound = 1; // 重试轮次
83
+ private maxRetryRound = 3; // 最大轮次
84
+ private isRetrying = false; // 重试中锁,避免并发调用
85
+ private isStartRetry = false;
86
+ private destroyed = false;
87
+ private incompleteInitializationCleanupPromise: Promise<void> | null = null;
88
+
89
+ // 音频捕获相关(用于 ASR 回声消除)
90
+ private captureAudioCtx: AudioContext | null = null;
91
+ private captureWorklet: AudioWorkletNode | null = null;
92
+ private captureSource: MediaElementAudioSourceNode | null = null;
93
+ private captureSetupPromise: Promise<void> | null = null;
94
+ private captureLastSpeechId: number = -2;
95
+ private restartPlaybackCaptureImpl: (() => void) | null = null;
96
+ private captureEncodeQueue: Promise<void> = Promise.resolve();
97
+ private captureGeneration = 0;
98
+
99
+ // PCM 捕获相关(raw_audio=true 模式)
100
+ private pcmCaptureLastSpeechId: number = -2;
101
+ private pcmCaptureFirstChunk: boolean = true;
102
+ private pcmWebMEncoder: PcmWebMEncoder | null = null;
103
+ private mediaRecorderEncoder: MediaRecorderEncoder | null = null;
104
+ private pcmCaptureMode: 'webcodec' | 'mediarecorder' | 'none' = 'none';
105
+ private pcmEncodeId: number = 0; // 用于 reset 竞态检测,递增标识当前编码会话
106
+
107
+ private reconnectDebounceTimer = -1; // 重连防抖定时器
108
+ private networkInfo: any = {
109
+ rtt: 0,
110
+ downlink: 0.0,
111
+ networkStatus: false,
112
+ };
113
+
114
+ private mediaRecorder: MediaRecorder | null = null;
115
+ private recordedChunks: Blob[] = [];
116
+ private stream: MediaStream | null = null;
117
+
118
+ // 薄客户端模式相关字段
119
+ private _isClientMode: boolean = false;
120
+ private _streamingClient: StreamingClient | null = null;
121
+ private _videoPlayer: VideoPlayer | null = null;
122
+ private _eventDispatcher: EventDispatcher | null = null;
123
+ private _apiForwarder: APIForwarder | null = null;
124
+ private _clientOnDownloadProgress: ((progress: number) => void) | null = null;
125
+ private _pendingInit: boolean = false;
126
+ private _pendingInitConfig: any = null;
127
+ private _clientSessionId: string = '';
128
+
129
+ constructor(options: IAvatarOptions) {
130
+ (window as any).avatarSDKLogger.setEnabled(options.enableLogger);
131
+ this.options = options;
132
+ this._env = options.env || "production"
133
+ this.enableClientInterrupt = options.enableClientInterrupt || false;
134
+ this.boundVisibilityChange = this.visibilitychange.bind(this);
135
+
136
+ // 薄客户端模式:跳过所有重量级初始化,仅准备容器
137
+ if (options.renderMode === 'client') {
138
+ this._isClientMode = true;
139
+ this.initializeClient(options);
140
+ return;
141
+ }
142
+
143
+ // 完整渲染模式(原有逻辑)
144
+ this.initializeSDK(options);
145
+ this.networkMonitor = new NetworkMonitor({
146
+ offlineCallback: (message) => {
147
+ this.onMessage({
148
+ code: EErrorCode.NETWORK_DOWN,
149
+ message: message || '网络断开',
150
+ });
151
+ console.log('网络断开(network off)', message);
152
+
153
+ // 如果初始化未完成(progress < 100),清除所有已初始化的资源并断开连接
154
+ if (!this.isInitialized) {
155
+ (window as any).avatarSDKLogger.warn(this.TAG, '初始化未完成时断网,清除所有资源并断开连接');
156
+ void this.cleanupBeforeInitComplete("network_offline_before_init");
157
+ return;
158
+ }
159
+
160
+ if(this.status !== AvatarStatus.offline) {
161
+ // 无网断开则进入离线模式
162
+ this.offlineHandle();
163
+ }
164
+ },
165
+ onlineCallback: (message) => {
166
+ this.onMessage({
167
+ code: EErrorCode.NETWORK_UP,
168
+ message: message || '网络恢复',
169
+ });
170
+
171
+ // 如果初始化未完成(progress < 100),清除所有已初始化的资源并断开连接
172
+ if (!this.isInitialized) {
173
+ (window as any).avatarSDKLogger.warn(this.TAG, '网络恢复但初始化未完成,清除所有资源并断开连接');
174
+ void this.cleanupBeforeInitComplete("network_online_before_init");
175
+ return;
176
+ }
177
+
178
+ // 网络恢复时,取消WebSocket的reconnectTimer,避免与网络恢复的重连逻辑冲突
179
+ this.ttsa?.clearReconnectTimer();
180
+
181
+ if(this.isStartRetry) {
182
+ this.triggerReconnect();
183
+ }
184
+ // this.onStatusChange(AvatarStatus.network_on)
185
+ // this._reload()
186
+ // this.ttsa?.connect()
187
+ },
188
+ // retryCallback: (count: number) => {
189
+ // this.onMessage({
190
+ // code: EErrorCode.NETWORK_RETRY,
191
+ // message: '网络重试中',
192
+ // });
193
+ // this.ttsa?.connect();
194
+ // return this.resourceManager._startSession();
195
+ // },
196
+ })
197
+ }
198
+
199
+ public getStatus() {
200
+ return this.status
201
+ }
202
+
203
+ public get businessENV() {
204
+ return this._env
205
+ }
206
+
207
+ public getUniqueSpeakId() {
208
+ return this.ttsa?.getUniqueSpeakId() || `0-${this.getSessionId()}`
209
+
210
+ }
211
+
212
+ private async initializeSDK(options: IAvatarOptions) {
213
+ if (!navigator.onLine) {
214
+ this.onMessage({
215
+ code: EErrorCode.NETWORK_BREAK,
216
+ message: '没有网络,请联网后重试',
217
+ });
218
+ return
219
+ }
220
+ const self = this;
221
+ const internalOptions = options as InternalAvatarOptions;
222
+ const { containerId, container, appId, appSecret, gatewayServer, cacheServer, config } = options;
223
+
224
+ const {scale = 1} = config?.layout?.avatar ?? {}
225
+ if(config?.layout?.avatar?.scale){
226
+ config.layout.avatar.scale = validateScaleWithDecimal(scale, "initializeSDK") as number | string;
227
+ }
228
+ const _config = { ...(config || {}), raw_audio: !isSupportMES() };
229
+
230
+ if(container && container instanceof HTMLElement){
231
+ this.el = container as HTMLElement;
232
+ } else {
233
+ const el = document.querySelector(containerId) as HTMLElement;
234
+ if (!el) {
235
+ this.onMessage({
236
+ code: EErrorCode.CONTAINER_NOT_FOUND,
237
+ message: `containerId: ${containerId} 不存在`,
238
+ })
239
+ return;
240
+ }
241
+ this.el = el;
242
+ }
243
+ const bgContainer = document.createElement("div");
244
+ bgContainer.setAttribute("id", "avatar-bg-container");
245
+ bgContainer.setAttribute("style", "position:absolute;top:0;left:0;width:100%;height:100%;z-index:0;opacity:0;");
246
+ this.el.appendChild(bgContainer);
247
+ const style = this.el.getAttribute("style");
248
+ if (style) {
249
+ this.el.setAttribute(
250
+ "style",
251
+ `position:relative;overflow:hidden;${style}`
252
+ );
253
+ } else {
254
+ this.el.setAttribute(
255
+ "style",
256
+ `position:relative;overflow:hidden;`
257
+ );
258
+ }
259
+ this.resourceManager = new ResourceManager({
260
+ sdkInstance: this,
261
+ headers: options.headers,
262
+ tag: options.tag,
263
+ gateway_type: options.gateway_type || 'default',
264
+ asr_id: options.asr_id,
265
+ llm_id: options.llm_id,
266
+ session_speak_req_id: options.session_speak_req_id,
267
+ sessionRequestData: internalOptions.sessionRequestData,
268
+ appId,
269
+ appSecret,
270
+ gatewayServer,
271
+ cacheServer,
272
+ config:_config,
273
+ onNetworkInfo(networkInfo) {
274
+ self.networkInfo = networkInfo;
275
+ options.onNetworkInfo?.(networkInfo);
276
+ },
277
+ onStartSessionWarning: (message: Object) => {
278
+ this.options.onStartSessionWarning?.(message);
279
+ },
280
+ });
281
+
282
+ this.renderScheduler = new RenderScheduler({
283
+ sdkInstance: this,
284
+ container: this.el,
285
+ resourceManager: this.resourceManager,
286
+ enableDebugger: options.enableDebugger,
287
+ hardwareAcceleration: options.hardwareAcceleration || "default",
288
+ enableClientInterrupt: this.enableClientInterrupt,
289
+ audioOnlyMode: options.audioOnlyMode || false,
290
+ onDownloadProgress: (progress: number) => {
291
+ this.onDownloadProgress?.(progress);
292
+ this.el?.setAttribute("style", this.el?.style.cssText + `opacity:${progress === 100 ? '1' : '0'};`);
293
+ // 标记初始化完成
294
+ this.isInitialized = progress === 100;
295
+ if(progress === 100) {
296
+ this.resourceManager._setOfflineCache();
297
+ this.ttsa?.sendSdkPoint('loadvideo_decode_time', {
298
+ timestamp: Date.now(),
299
+ });
300
+ setTimeout(() => {
301
+ this.ttsa?.sendDeviceInfo({
302
+ ...getDeviceInfo(),
303
+ ...(window as any).performanceTracker.getFirstInfo(),
304
+ // @ts-ignore
305
+ sdk_version: VERSION,
306
+ resolution: this.resourceManager.getConfig()?.resolution,
307
+ download_speed: self.networkInfo.downlink || 0,
308
+ })
309
+ }, 20);
310
+ // 初始化音频捕获(MSE 模式下,用于 ASR 回声消除)
311
+ void this.enableAudioPlaybackCapture();
312
+ }
313
+ },
314
+ onStateChange: (state: string) => {
315
+ if((this.getStatus() === AvatarStatus.offline && state !== 'idle') || state === "") {
316
+ return
317
+ }
318
+ options.onStateChange?.(state);
319
+ },
320
+ onRenderChange: (state: RenderState, oldState?: RenderState) => {
321
+ options.onRenderChange?.(state);
322
+ // 在如果数字人在恢复渲染态一会和数字人在线态的时候,数字人进入渲染中了,都应该认为是可见状态
323
+ if(state === RenderState.rendering && (oldState === RenderState.resumed || this.getStatus() === AvatarStatus.online) && this.isInitialized) {
324
+ (window as any).avatarSDKLogger.log(this.TAG, 'Status:===== 渲染中,状态: rendering', this.getSessionId());
325
+ this.onStatusChange(AvatarStatus.visible)
326
+ }
327
+ },
328
+ onVoiceStateChange: (state: string, duration?: number, client_speak_id?: string | number) => {
329
+ options.onVoiceStateChange?.(state, duration, client_speak_id);
330
+ },
331
+ onWalkStateChange: (state: string) => {
332
+ options.onWalkStateChange?.(state);
333
+ },
334
+ sendVideoInfo: (info: {name: string, body_id: number, id: number}) => {
335
+ this.debugOverlay?.setVideoInfo(info);
336
+ },
337
+ onSpeakStateChange: (state: string, client_speak_id: string | number) => {
338
+ options.onSpeakStateChange?.(state, client_speak_id);
339
+ },
340
+ setAudioInfo: (info: {
341
+ sf: number;
342
+ ef: number;
343
+ ad: Uint8Array;
344
+ }) => {
345
+ this.debugOverlay?.setAudioInfo(info);
346
+ },
347
+ setEventData: (info: {
348
+ sf: number;
349
+ ef: number;
350
+ event: Array<any>;
351
+ }) => {
352
+ this.debugOverlay?.setEventData(info);
353
+ },
354
+ renderFrameCallback: (frame: number) => {
355
+ this.renderFrameCallback(frame);
356
+ this.handleReplaySend(frame);
357
+ },
358
+ reportMessage: (message: {
359
+ code: EErrorCode;
360
+ message: string;
361
+ e?: object;
362
+ }) => {
363
+ this.onMessage(message);
364
+ },
365
+ sendSdkPoint: (type: string, data: any, extra?: any) => {
366
+ this.ttsa?.sendSdkPoint(type, data, extra);
367
+ },
368
+ sendMessage: (type: string, payload: any) => {
369
+ this.ttsa?.send(type, payload);
370
+ },
371
+ onFPSUpdate: (stats: FPSStats) => {
372
+ options.onFPSUpdate?.(stats);
373
+ },
374
+ onFrameDataUpdate: (data) => {
375
+ options.onFrameDataUpdate?.(data);
376
+ },
377
+ });
378
+ options.onStateRenderChange && (window as any).performanceTracker.setOnStateRenderChange(options.onStateRenderChange);
379
+
380
+
381
+ if(config?.walk_config){
382
+ this.changeWalkConfig(config.walk_config)
383
+ }
384
+ if(config?.layout) {
385
+ this.changeLayout(config.layout);
386
+ }
387
+ WidgetDefaultRender.CUSTOM_WIDGET = options.onWidgetEvent;
388
+ WidgetDefaultRender.PROXY_WIDGET = options.proxyWidget || {};
389
+ }
390
+
391
+ /**
392
+ * 薄客户端模式初始化:创建容器、StreamingClient、VideoPlayer、EventDispatcher、APIForwarder
393
+ */
394
+ private initializeClient(options: IAvatarOptions) {
395
+ const { containerId, container } = options;
396
+
397
+ // 准备容器
398
+ if (container && container instanceof HTMLElement) {
399
+ this.el = container;
400
+ } else {
401
+ const el = document.querySelector(containerId) as HTMLElement;
402
+ if (!el) {
403
+ this.options.onMessage?.({ code: EErrorCode.CONTAINER_NOT_FOUND, message: `containerId: ${containerId} 不存在` } as any);
404
+ return;
405
+ }
406
+ this.el = el;
407
+ }
408
+ const style = this.el.getAttribute("style");
409
+ if (style) {
410
+ this.el.setAttribute("style", `position:relative;overflow:hidden;${style}`);
411
+ } else {
412
+ this.el.setAttribute("style", "position:relative;overflow:hidden;");
413
+ }
414
+
415
+ // 创建 EventDispatcher
416
+ this._eventDispatcher = new EventDispatcher({
417
+ onStateChange: options.onStateChange,
418
+ onSpeakStateChange: options.onSpeakStateChange,
419
+ onVoiceStateChange: options.onVoiceStateChange,
420
+ onStatusChange: options.onStatusChange,
421
+ onRenderChange: options.onRenderChange,
422
+ onWalkStateChange: options.onWalkStateChange,
423
+ onWidgetEvent: options.onWidgetEvent,
424
+ onMessage: options.onMessage,
425
+ onNetworkInfo: options.onNetworkInfo,
426
+ onFPSUpdate: options.onFPSUpdate,
427
+ onFrameDataUpdate: options.onFrameDataUpdate,
428
+ onDownloadProgress: (progress: number) => {
429
+ this._clientOnDownloadProgress?.(progress);
430
+ if (progress >= 100 && !this.isInitialized) {
431
+ this.isInitialized = true;
432
+ }
433
+ },
434
+ onSessionId: (sessionId: string) => {
435
+ this._clientSessionId = sessionId;
436
+ (window as any).avatarSDKLogger.log(this.TAG, '[client] 收到 session_id:', sessionId);
437
+ },
438
+ });
439
+
440
+ const streamingUrl = options.streamingServer || `ws://${location.hostname}:8080`;
441
+
442
+ // 创建 StreamingClient
443
+ this._streamingClient = new StreamingClient({
444
+ url: streamingUrl,
445
+ onReady: () => {
446
+ (window as any).avatarSDKLogger.log(this.TAG, '[client] 双方就绪,等待 Server 推流...');
447
+ // 如果 init() 在 WebSocket 连接前被调用,在此补发
448
+ if (this._pendingInit) {
449
+ this._pendingInit = false;
450
+ const config = this._pendingInitConfig;
451
+ this._pendingInitConfig = null;
452
+ (window as any).avatarSDKLogger.log(this.TAG, '[client] 补发 init 命令');
453
+ this._apiForwarder?.init(config);
454
+ }
455
+ },
456
+ onOffer: (sdp: string) => {
457
+ this._videoPlayer?.handleOffer(sdp);
458
+ },
459
+ onIce: (candidate: RTCIceCandidateInit) => {
460
+ this._videoPlayer?.addIceCandidate(candidate);
461
+ },
462
+ onEventProxy: (eventType: string, data: any, serverFrame?: number) => {
463
+ this._eventDispatcher?.dispatch(eventType, data, serverFrame);
464
+ },
465
+ onError: (error: string) => {
466
+ this.options.onMessage?.({ code: EErrorCode.NETWORK_DOWN, message: error } as any);
467
+ },
468
+ onDisconnect: () => {
469
+ this.onStatusChange(AvatarStatus.offline);
470
+ },
471
+ });
472
+
473
+ // 创建 VideoPlayer
474
+ this._videoPlayer = new VideoPlayer({
475
+ container: this.el,
476
+ streamingClient: this._streamingClient,
477
+ useAlphaMask: options.useAlphaMask,
478
+ onConnected: () => {
479
+ (window as any).avatarSDKLogger.log(this.TAG, '[client] WebRTC 已连接');
480
+ this.onStatusChange(AvatarStatus.online);
481
+ },
482
+ onDisconnected: () => {
483
+ this.onStatusChange(AvatarStatus.offline);
484
+ },
485
+ });
486
+
487
+ // 创建 APIForwarder
488
+ this._apiForwarder = new APIForwarder(this._streamingClient);
489
+
490
+ // 连接信令
491
+ this._streamingClient.connect();
492
+
493
+ window.addEventListener('beforeunload', this.handleBeforeUnload);
494
+ }
495
+
496
+ // async preCache() {
497
+ // return this.resourceManager.preCache(this.options.appId, this.options.appSecret);
498
+ // }
499
+
500
+ private renderFrameCallback(frame: number) {
501
+ // 间隔24帧执行一次
502
+ if(!this.ttsa?.getStatus() && ![AvatarStatus.offline].includes(this.status as AvatarStatus) && frame % 24 === 0) {
503
+ this.offlineHandle()
504
+ }
505
+ }
506
+
507
+ async init(params: IInitParams): Promise<ISessionResponse | null | undefined> {
508
+ // 浏览器能力检查
509
+ const capabilityReport = await checkCapability(this.options.audioOnlyMode);
510
+ if (!capabilityReport.supported) {
511
+ const fatalItems = capabilityReport.items.filter(i => i.severity === 'fatal' && !i.supported);
512
+ const errorMsg = `浏览器能力检查失败: ${fatalItems.map(i => i.reason).join('; ')}`;
513
+ this.onMessage({
514
+ code: EErrorCode.INIT_ERROR,
515
+ message: errorMsg,
516
+ });
517
+ return;
518
+ }
519
+
520
+ this.destroyed = false;
521
+ this.isInitialized = false; // 重置初始化状态
522
+ this.incompleteInitializationCleanupPromise = null;
523
+ this.startConnectTime = Date.now();
524
+
525
+ // 薄客户端模式:保存进度回调,通知 server 端开始初始化,等待 server 的 download_progress 事件
526
+ if (this._isClientMode) {
527
+ this._clientOnDownloadProgress = params.onDownloadProgress || null;
528
+ // 构建发给 server 的配置(排除客户端专用字段)
529
+ const clientConfig = {
530
+ appId: this.options.appId,
531
+ appSecret: this.options.appSecret,
532
+ gatewayServer: this.options.gatewayServer,
533
+ cacheServer: this.options.cacheServer,
534
+ headers: this.options.headers,
535
+ tag: this.options.tag,
536
+ gateway_type: this.options.gateway_type,
537
+ env: this.options.env,
538
+ config: this.options.config,
539
+ hardwareAcceleration: this.options.hardwareAcceleration,
540
+ enableDebugger: this.options.enableDebugger,
541
+ initModel: params.initModel,
542
+ scaleDownBy: this.options.scaleDownBy || 1,
543
+ useAlphaMask: this.options.useAlphaMask,
544
+ };
545
+ // 发送 init 命令通知 server 端初始化数字人
546
+ // 如果 WebSocket 尚未连接,暂存等 onReady 时补发
547
+ if (this._streamingClient?.isConnected()) {
548
+ this._apiForwarder?.init(clientConfig);
549
+ } else {
550
+ this._pendingInit = true;
551
+ this._pendingInitConfig = clientConfig;
552
+ (window as any).avatarSDKLogger.log(this.TAG, '[client] WebSocket 未连接,init 命令暂存');
553
+ }
554
+ return;
555
+ }
556
+
557
+ const { onDownloadProgress, initModel } = params;
558
+ this.onDownloadProgress = (progress: number) => {
559
+ if(progress === 15) {
560
+ this.sessionResTime = Date.now();
561
+ }
562
+ if(progress === 80) {
563
+ this.charbinResTime = Date.now();
564
+ }
565
+ onDownloadProgress(progress);
566
+ };
567
+
568
+ // 如果初始化时指定了隐身模式,先标记待处理,等start时再真正应用
569
+ if(initModel === InitModel.invisible) {
570
+ this.pendingInvisibleMode = true;
571
+ }
572
+
573
+ // 加载资源
574
+ (window as any).performanceTracker.markStart(performanceConstant.load_resource);
575
+ (window as any).performanceTracker.markStart(performanceConstant.first_avatar_render);
576
+ (window as any).performanceTracker.markStart(performanceConstant.first_webgl_render);
577
+ const sessionInfo = await this.resourceManager.load(this.onDownloadProgress.bind(this));
578
+ // 如果在load过程中被销毁了,直接返回,避免继续初始化
579
+ if(this.destroyed) {
580
+ (window as any).avatarSDKLogger.warn(this.TAG, "sdk destroyed during load, aborting after resourceManager load");
581
+ return
582
+ };
583
+ if(!sessionInfo?.socket_io_url || !sessionInfo?.token) {
584
+ (window as any).avatarSDKLogger.error(this.TAG, "init error, socket_io_url or token is empty reload");
585
+ return
586
+ };
587
+ this.connectSuccessTime = Date.now();
588
+ (window as any).performanceTracker.markEnd(performanceConstant.load_resource);
589
+ this.debugOverlay = new DebugOverlay(this, sessionInfo);
590
+
591
+ this.ttsa = this.connectTtsa(sessionInfo)
592
+ this.renderScheduler.init();
593
+ // 同步初始canvas显隐状态
594
+ this.renderScheduler.setAvatarCanvasVisible(this.avatarCanvasVisible);
595
+ // 设置上报使用ttsa
596
+ (window as any).performanceTracker.setReportFunc(this.ttsa);
597
+
598
+ window.addEventListener('beforeunload', this.handleBeforeUnload);
599
+ document.addEventListener('visibilitychange', this.boundVisibilityChange);
600
+ return sessionInfo;
601
+ }
602
+
603
+ /**
604
+ * 初始化音频捕获(MSE 模式)
605
+ * 通过 AudioContext.createMediaElementSource 接管 <audio> 元素输出,
606
+ * 按 1600 samples 分桶后使用 WebCodecs 编码为 opus (WebM 容器),
607
+ * 并回调给业务侧用于 ASR 回声消除(AEC)。
608
+ *
609
+ * 架构:
610
+ * - AudioWorklet 负责 100ms 分桶和静音检测
611
+ * - WebCodecs AudioEncoder + WebMMuxer 负责 opus/WebM 编码
612
+ * - 每次 speak 会话的首段音频自动包含 WebM 容器头(isFirstChunk=true)
613
+ *
614
+ * 注意:
615
+ * - createMediaElementSource 对同一个 audio 元素只能调用一次
616
+ * - 接管后必须手动 connect 到 destination,否则音频不会播放
617
+ * - MSE 播放器 stop 时只销毁 MediaSource,audio 元素不变,捕获绑定不受影响
618
+ */
619
+ protected setAudioPlaybackDataHandler(handler?: IAvatarOptions["onAudioPlaybackData"]) {
620
+ this.options.onAudioPlaybackData = handler;
621
+ }
622
+
623
+ protected restartAudioPlaybackCapture() {
624
+ this.restartPlaybackCaptureImpl?.();
625
+ }
626
+
627
+ protected enableAudioPlaybackCapture() {
628
+ if (!this.options.onAudioPlaybackData) {
629
+ return Promise.resolve();
630
+ }
631
+ if (!this.captureSetupPromise) {
632
+ if (this.resourceManager?.config.raw_audio) {
633
+ // PCM 模式:通过 capture worklet 捕获并编码
634
+ this.captureSetupPromise = this.setupPCMCapture().finally(() => {
635
+ this.captureSetupPromise = null;
636
+ });
637
+ } else {
638
+ // MSE/WebM 模式:通过 AudioWorklet 分桶并用 WebCodecs 编码
639
+ this.captureSetupPromise = this.setupAudioCapture().finally(() => {
640
+ this.captureSetupPromise = null;
641
+ });
642
+ }
643
+ }
644
+ return this.captureSetupPromise;
645
+ }
646
+
647
+ private async setupAudioCapture() {
648
+ const audioEl = document.getElementById('xmov-avatar-audio') as HTMLAudioElement;
649
+ if (!audioEl || !this.options.onAudioPlaybackData) return;
650
+ if (this.captureAudioCtx || this.captureSource) return;
651
+
652
+ // 检查 AudioWorkletNode 支持
653
+ if (typeof (window as any).AudioWorkletNode === 'undefined') {
654
+ this.onMessage({
655
+ code: EErrorCode.INIT_ERROR,
656
+ message: '当前浏览器不支持 AudioWorkletNode,无法启用音频捕获(ASR 回声消除)',
657
+ });
658
+ return;
659
+ }
660
+
661
+ try {
662
+ if (!await PcmWebMEncoder.isSupported()) {
663
+ this.onMessage({
664
+ code: EErrorCode.INIT_ERROR,
665
+ message: '当前浏览器不支持 WebCodecs AudioEncoder Opus,无法启用 100ms 双路音频',
666
+ });
667
+ return;
668
+ }
669
+ this.pcmWebMEncoder = new PcmWebMEncoder();
670
+ await this.pcmWebMEncoder.init();
671
+
672
+ // 创建 16kHz 采样率的 AudioContext(farend 协议要求)
673
+ this.captureAudioCtx = new AudioContext({ sampleRate: 16000 });
674
+ if (this.captureAudioCtx.sampleRate !== 16000) {
675
+ throw new Error(`浏览器无法创建 16000 Hz AudioContext: ${this.captureAudioCtx.sampleRate}`);
676
+ }
677
+ this.captureSource = this.captureAudioCtx.createMediaElementSource(audioEl);
678
+
679
+ // 路由到扬声器(必须连接,否则无声)
680
+ this.captureSource.connect(this.captureAudioCtx.destination);
681
+
682
+ let isRecording = false;
683
+ let isFirstEmit = true;
684
+ let captureStartedAt = 0;
685
+ let captureSequence = 0;
686
+ const startCapture = (sessionId: number) => {
687
+ const generation = ++this.captureGeneration;
688
+ isRecording = true;
689
+ isFirstEmit = true;
690
+ captureStartedAt = Date.now() - 100;
691
+ captureSequence = 0;
692
+ this.captureLastSpeechId = sessionId;
693
+ this.captureEncodeQueue = this.captureEncodeQueue.then(() => {
694
+ if (generation === this.captureGeneration) {
695
+ this.pcmWebMEncoder?.reset();
696
+ }
697
+ });
698
+ };
699
+
700
+ const stopCapture = () => {
701
+ const generation = ++this.captureGeneration;
702
+ isRecording = false;
703
+ this.captureLastSpeechId = -2;
704
+ this.captureEncodeQueue = this.captureEncodeQueue.then(() => {
705
+ if (generation === this.captureGeneration) {
706
+ this.pcmWebMEncoder?.reset();
707
+ }
708
+ });
709
+ };
710
+
711
+ this.restartPlaybackCaptureImpl = () => {
712
+ if (isRecording) {
713
+ startCapture(this.renderScheduler.getSpeechId());
714
+ }
715
+ };
716
+
717
+ // 创建 AudioWorkletProcessor 脚本(在音频线程中运行)
718
+ // 缓冲 1600 个采样点(100ms @ 16kHz),直接回传 S16LE PCM。
719
+ const processorCode = `
720
+ class AudioCaptureProcessor extends AudioWorkletProcessor {
721
+ constructor() {
722
+ super();
723
+ this.buffer = new Float32Array(1600); // 100ms @ 16kHz
724
+ this.bufferIndex = 0;
725
+ }
726
+
727
+ process(inputs, outputs) {
728
+ for (const output of outputs) {
729
+ for (const channel of output) channel.fill(0);
730
+ }
731
+ const input = inputs[0];
732
+ if (input && input.length > 0 && input[0] && input[0].length > 0) {
733
+ const frameCount = input[0].length;
734
+
735
+ for (let i = 0; i < frameCount; i++) {
736
+ let sample = 0;
737
+ for (let channel = 0; channel < input.length; channel++) {
738
+ sample += input[channel][i] || 0;
739
+ }
740
+ this.buffer[this.bufferIndex++] = sample / input.length;
741
+
742
+ // 缓冲区满 1600 采样点时,检测静音并发送到主线程
743
+ if (this.bufferIndex >= 1600) {
744
+ // 检测是否全是静音(全零或极低值)
745
+ let hasAudio = false;
746
+ for (let j = 0; j < 1600; j++) {
747
+ if (Math.abs(this.buffer[j]) > 0.0001) {
748
+ hasAudio = true;
749
+ break;
750
+ }
751
+ }
752
+
753
+ const pcm = new Int16Array(1600);
754
+ for (let j = 0; j < 1600; j++) {
755
+ const value = Math.max(-1, Math.min(1, this.buffer[j]));
756
+ pcm[j] = value < 0 ? value * 0x8000 : value * 0x7fff;
757
+ }
758
+ this.port.postMessage(
759
+ { hasAudio: hasAudio, pcmData: pcm.buffer },
760
+ [pcm.buffer]
761
+ );
762
+ this.bufferIndex = 0;
763
+ }
764
+ }
765
+ }
766
+ return true;
767
+ }
768
+ }
769
+ registerProcessor('audio-capture-processor', AudioCaptureProcessor);
770
+ `;
771
+
772
+ // 通过 Blob URL 加载 processor
773
+ const blob = new Blob([processorCode], { type: 'application/javascript' });
774
+ const processorUrl = URL.createObjectURL(blob);
775
+ await this.captureAudioCtx.audioWorklet.addModule(processorUrl);
776
+ URL.revokeObjectURL(processorUrl);
777
+
778
+ // 创建 AudioWorkletNode(仅用于静音检测,不连接 destination)
779
+ this.captureWorklet = new AudioWorkletNode(
780
+ this.captureAudioCtx,
781
+ 'audio-capture-processor'
782
+ );
783
+ this.captureSource.connect(this.captureWorklet);
784
+ this.captureWorklet.connect(this.captureAudioCtx.destination);
785
+
786
+ this.captureEncodeQueue = Promise.resolve();
787
+ this.captureWorklet.port.onmessage = (e) => {
788
+ if (!this.options.onAudioPlaybackData) return;
789
+
790
+ const { hasAudio, pcmData } = e.data as { hasAudio: boolean; pcmData: ArrayBuffer };
791
+ const speechId = this.renderScheduler.getSpeechId();
792
+
793
+ if (hasAudio && !isRecording) {
794
+ startCapture(speechId);
795
+ } else if (hasAudio && isRecording && speechId !== this.captureLastSpeechId) {
796
+ startCapture(speechId);
797
+ } else if (!hasAudio && isRecording) {
798
+ stopCapture();
799
+ }
800
+ if (!hasAudio || !isRecording || !(pcmData instanceof ArrayBuffer)) return;
801
+
802
+ const generation = this.captureGeneration;
803
+ const firstChunk = isFirstEmit;
804
+ isFirstEmit = false;
805
+ const blockStartedAt = captureStartedAt + captureSequence * 100;
806
+ captureSequence += 1;
807
+ this.captureEncodeQueue = this.captureEncodeQueue
808
+ .then(async () => {
809
+ const buffer = await this.pcmWebMEncoder?.encode(new Int16Array(pcmData));
810
+ if (!buffer || buffer.byteLength === 0 || generation !== this.captureGeneration) return;
811
+ this.options.onAudioPlaybackData?.({
812
+ data: buffer,
813
+ codec: 'opus',
814
+ isFirstChunk: firstChunk,
815
+ sampleRate: 16000,
816
+ samples: 1600,
817
+ timestamp: blockStartedAt,
818
+ speech_id: speechId,
819
+ });
820
+ })
821
+ .catch((error) => {
822
+ (window as any).avatarSDKLogger?.error(
823
+ this.TAG,
824
+ '[AudioCapture] PCM/WebCodecs 编码失败:',
825
+ error
826
+ );
827
+ });
828
+ };
829
+
830
+ // 处理 AudioContext 挂起(浏览器自动播放策略)
831
+ if (this.captureAudioCtx.state === 'suspended') {
832
+ await this.captureAudioCtx.resume();
833
+ }
834
+ if (this.captureAudioCtx.state !== 'running') {
835
+ throw new Error('AudioContext 未运行,无法启动 farend PCM 捕获');
836
+ }
837
+
838
+ (window as any).avatarSDKLogger?.log(
839
+ this.TAG,
840
+ '[AudioCapture] 初始化成功,采样率:',
841
+ this.captureAudioCtx.sampleRate,
842
+ 'Hz,opus 编码 (WebM 容器)'
843
+ );
844
+ } catch (e) {
845
+ this.captureGeneration++;
846
+ this.restartPlaybackCaptureImpl = null;
847
+ if (this.captureWorklet) {
848
+ try {
849
+ this.captureWorklet.port.onmessage = null;
850
+ this.captureWorklet.disconnect();
851
+ } catch {}
852
+ this.captureWorklet = null;
853
+ }
854
+ try {
855
+ this.captureSource?.disconnect();
856
+ } catch {}
857
+ this.captureSource = null;
858
+ if (this.captureAudioCtx && this.captureAudioCtx.state !== 'closed') {
859
+ await this.captureAudioCtx.close().catch(() => undefined);
860
+ }
861
+ this.captureAudioCtx = null;
862
+ void this.pcmWebMEncoder?.destroy();
863
+ this.pcmWebMEncoder = null;
864
+ (window as any).avatarSDKLogger?.error(
865
+ this.TAG,
866
+ '[AudioCapture] 初始化失败:',
867
+ e
868
+ );
869
+ }
870
+ }
871
+
872
+ /**
873
+ * 初始化 PCM 模式音频捕获(raw_audio=true 时使用)
874
+ * 通过 PCMAudioPlayer 的 capture worklet 拦截音频输出,
875
+ * 经 WebCodecs 或 MediaRecorder 编码为 WebM/Opus 后回调给业务侧
876
+ */
877
+ private async setupPCMCapture() {
878
+ if (!this.options.onAudioPlaybackData) return;
879
+
880
+ const audioPlayer = this.renderScheduler.getAudioRenderer()?.getAudioPlayer();
881
+ if (!audioPlayer) {
882
+ (window as any).avatarSDKLogger?.error(
883
+ this.TAG, '[PCMCapture] PCMAudioPlayer 未就绪'
884
+ );
885
+ return;
886
+ }
887
+
888
+ // 运行时检测编码能力,选择编码路径
889
+ const webCodecsSupported = await PcmWebMEncoder.isSupported();
890
+ const mediaRecorderSupported = MediaRecorderEncoder.isSupported();
891
+
892
+ if (!webCodecsSupported && !mediaRecorderSupported) {
893
+ (window as any).avatarSDKLogger?.warn(
894
+ this.TAG, '[PCMCapture] 当前浏览器不支持 Opus 编码(WebCodecs 和 MediaRecorder 均不可用),跳过 PCM 捕获'
895
+ );
896
+ this.onMessage({
897
+ code: EErrorCode.INIT_ERROR,
898
+ message: '当前浏览器不支持 Opus 编码,PCM 模式下无法启用音频捕获(ASR 回声消除)',
899
+ });
900
+ return;
901
+ }
902
+
903
+ try {
904
+ if (webCodecsSupported) {
905
+ // 首选路径:WebCodecs AudioEncoder
906
+ this.pcmWebMEncoder = new PcmWebMEncoder();
907
+ await this.pcmWebMEncoder.init();
908
+ this.pcmCaptureMode = 'webcodec';
909
+ } else {
910
+ // 降级路径:MediaRecorder
911
+ this.mediaRecorderEncoder = new MediaRecorderEncoder();
912
+ await this.mediaRecorderEncoder.init();
913
+ this.pcmCaptureMode = 'mediarecorder';
914
+ }
915
+
916
+ this.restartPlaybackCaptureImpl = () => {
917
+ this.pcmCaptureFirstChunk = true;
918
+ this.pcmCaptureLastSpeechId = this.renderScheduler.getSpeechId();
919
+ this.pcmEncodeId++;
920
+ this.pcmWebMEncoder?.reset();
921
+ this.mediaRecorderEncoder?.reset();
922
+ audioPlayer.resetCaptureBuffer();
923
+ };
924
+
925
+ await audioPlayer.attachCapture(async ({ pcmData, hasAudio }) => {
926
+ try {
927
+ if (!this.options.onAudioPlaybackData) return;
928
+ const blockStartedAt = Date.now() - 100;
929
+
930
+ // 音频未播放时丢弃数据(speak_end 后 capture worklet 可能还有尾巴数据)
931
+ if (!audioPlayer.isPlaying) {
932
+ audioPlayer.resetCaptureBuffer();
933
+ return;
934
+ }
935
+
936
+ const speechId = this.renderScheduler.getSpeechId();
937
+
938
+ // 会话变化时重置
939
+ if (speechId !== this.pcmCaptureLastSpeechId) {
940
+ this.pcmCaptureFirstChunk = true;
941
+ this.pcmCaptureLastSpeechId = speechId;
942
+ this.pcmEncodeId++; // 递增编码会话 ID
943
+ this.pcmWebMEncoder?.reset();
944
+ this.mediaRecorderEncoder?.reset();
945
+ // 清空 capture worklet 内部缓冲区,避免打断后旧音频数据混入新 session
946
+ audioPlayer.resetCaptureBuffer();
947
+ }
948
+
949
+ // 静音时不发送
950
+ if (!hasAudio) return;
951
+
952
+ // 记录当前编码会话 ID(用于竞态检测)
953
+ const currentEncodeId = this.pcmEncodeId;
954
+
955
+ let webmData: ArrayBuffer | null = null;
956
+ const startTime = performance.now();
957
+
958
+ if (this.pcmCaptureMode === 'webcodec' && this.pcmWebMEncoder) {
959
+ // WebCodecs 路径:PCM → Opus → WebM
960
+ const pcm = new Int16Array(pcmData);
961
+ webmData = await this.pcmWebMEncoder.encode(pcm);
962
+
963
+ // 竞态检测:如果 reset 已被调用,丢弃这个结果
964
+ if (currentEncodeId !== this.pcmEncodeId) {
965
+ (window as any).avatarSDKLogger?.log(
966
+ this.TAG, '[PCMCapture] 丢弃过期编码数据(编码会话已重置)'
967
+ );
968
+ return;
969
+ }
970
+
971
+ if (webmData.byteLength === 0) return;
972
+
973
+ // 性能监控
974
+ const encodeTime = performance.now() - startTime;
975
+ if (encodeTime > 10) {
976
+ (window as any).avatarSDKLogger?.warn(
977
+ this.TAG, `[PCMCapture] WebCodecs 编码耗时过长: ${encodeTime.toFixed(2)}ms`
978
+ );
979
+ }
980
+ } else if (this.pcmCaptureMode === 'mediarecorder' && this.mediaRecorderEncoder) {
981
+ // MediaRecorder 降级路径
982
+ webmData = await this.mediaRecorderEncoder.feedPCM(pcmData);
983
+
984
+ // 竞态检测
985
+ if (currentEncodeId !== this.pcmEncodeId) {
986
+ (window as any).avatarSDKLogger?.log(
987
+ this.TAG, '[PCMCapture] 丢弃过期编码数据(编码会话已重置)'
988
+ );
989
+ return;
990
+ }
991
+
992
+ if (!webmData) return; // 首次启动无数据
993
+
994
+ // 性能监控
995
+ const encodeTime = performance.now() - startTime;
996
+ if (encodeTime > 10) {
997
+ (window as any).avatarSDKLogger?.warn(
998
+ this.TAG, `[PCMCapture] MediaRecorder 编码耗时过长: ${encodeTime.toFixed(2)}ms`
999
+ );
1000
+ }
1001
+ }
1002
+
1003
+ if (!webmData) return;
1004
+
1005
+ const firstChunk = this.pcmCaptureFirstChunk;
1006
+ this.pcmCaptureFirstChunk = false;
1007
+
1008
+ // 回调(格式与 MSE 模式完全一致)
1009
+ this.options.onAudioPlaybackData({
1010
+ data: webmData,
1011
+ codec: 'opus',
1012
+ isFirstChunk: firstChunk,
1013
+ sampleRate: 16000,
1014
+ samples: 1600,
1015
+ timestamp: blockStartedAt,
1016
+ speech_id: speechId,
1017
+ });
1018
+ } catch (error) {
1019
+ (window as any).avatarSDKLogger?.error(
1020
+ this.TAG, '[PCMCapture] 捕获回调错误:', error
1021
+ );
1022
+ }
1023
+ });
1024
+
1025
+ (window as any).avatarSDKLogger?.log(
1026
+ this.TAG, `[PCMCapture] PCM 捕获初始化成功,编码路径: ${this.pcmCaptureMode}`
1027
+ );
1028
+ } catch (e) {
1029
+ (window as any).avatarSDKLogger?.error(
1030
+ this.TAG, '[PCMCapture] PCM 捕获初始化失败:', e
1031
+ );
1032
+ void this.pcmWebMEncoder?.destroy();
1033
+ this.pcmWebMEncoder = null;
1034
+ this.mediaRecorderEncoder?.destroy();
1035
+ this.mediaRecorderEncoder = null;
1036
+ this.pcmCaptureMode = 'none';
1037
+ }
1038
+ }
1039
+
1040
+ visibilitychange() {
1041
+ if (document.hidden) {
1042
+ // this.offlineMode()
1043
+ // tab 回来时,强制同步解帧队列
1044
+ // this.renderScheduler.forceSyncDecoder();
1045
+ } else {
1046
+ if (this.ttsa?.getStatus()) {
1047
+ this.renderScheduler.forceSyncDecoder();
1048
+ }
1049
+ // tab 切换回来时,恢复音频捕获的 AudioContext
1050
+ if (this.captureAudioCtx && this.captureAudioCtx.state === 'suspended') {
1051
+ this.captureAudioCtx.resume().catch(() => {});
1052
+ }
1053
+ }
1054
+ }
1055
+
1056
+ setReplayData(data: any) {
1057
+ this.replayData = data;
1058
+ }
1059
+
1060
+ handleReplaySend(frame: number) {
1061
+ if(!this.replayData) {
1062
+ return;
1063
+ }
1064
+ const frameReplayData = this.replayData.filter((item: any) => (Number(item.client_frame_number) === frame && item.event_type !== "sdk_burial_point"));
1065
+ for(const item of frameReplayData) {
1066
+ if(item.event_type === 'state_change') {
1067
+ this.ttsa?.stateChange(item.event_data.state, item.event_data.params);
1068
+ }else if(item.event_type === 'send_text') {
1069
+ this.speak(item.event_data.ssml, item.event_data.is_start, item.event_data.is_end, item.event_data.extra);
1070
+
1071
+ }else {
1072
+ (window as any).avatarSDKLogger.log(this.TAG, `[handleReplaySend] 未知事件类型: ${item}`);
1073
+ }
1074
+ }
1075
+ }
1076
+
1077
+ // 初始化 ttsa
1078
+ private connectTtsa(sessionInfo: ISessionResponse) {
1079
+ (window as any).performanceTracker.markStart(performanceConstant.ttsa_connect);
1080
+ (window as any).performanceTracker.markStart(performanceConstant.ttsa_ready);
1081
+ return new Ttsa({
1082
+ sdkInstance: this,
1083
+ url: sessionInfo.socket_io_url,
1084
+ room: sessionInfo.room,
1085
+ session_id: sessionInfo.session_id,
1086
+ token: sessionInfo.token,
1087
+ framedata_proto_version: this.resourceManager.getConfig().framedata_proto_version,
1088
+ reconnect_client_timeout: sessionInfo?.reconnect_client_timeout || 0,
1089
+ appInfo: {
1090
+ ...this.resourceManager.getAppInfo(),
1091
+ env: this._env
1092
+ },
1093
+ onReady: () => {
1094
+ this.ttsa?.sendSdkPoint('connect_sdk', {
1095
+ timestamp: this.startConnectTime,
1096
+ });
1097
+ this.onDownloadProgress?.(85);
1098
+ this.ttsa?.sendSdkPoint('connect_sdk_success', {
1099
+ timestamp: this.connectSuccessTime,
1100
+ });
1101
+ this.ttsa?.sendSdkPoint('session_processing_time', {
1102
+ timestamp: this.sessionResTime,
1103
+ });
1104
+ this.ttsa?.sendSdkPoint('Char_bin_download_time', {
1105
+ timestamp: this.charbinResTime,
1106
+ });
1107
+ this.ttsa?.sendSdkPoint('socket_connect_time', {
1108
+ timestamp: Date.now(),
1109
+ });
1110
+ this.onTtsaReady(sessionInfo)
1111
+ this.ttsa?.syncTimestamp();
1112
+ },
1113
+ handleMessage: (type: EFrameDataType, data: IRawFrameData[] | SYNC_FRAME_DATA) => {
1114
+ if(this.isfirstReceive && type === EFrameDataType.BODY) {
1115
+ this.ttsa?.sendSdkPoint('body_completed_first_time', {
1116
+ timestamp: Date.now(),
1117
+ });
1118
+ this.isfirstReceive = false;
1119
+ }
1120
+ // 处理下行数据
1121
+ this.renderScheduler.handleData(type, data);
1122
+ },
1123
+ handleAAFrame: (data: any) => {
1124
+ this.options.onAAFrameHandle?.(data);
1125
+ },
1126
+ runStartFrameIndex: (client_time: number) => {
1127
+ this.renderScheduler.runStartFrameIndex();
1128
+ },
1129
+ ttsaStateChangeHandle: (state: StateChangeInfo) => {
1130
+ this.renderScheduler.ttsaStateChangeHandle(state);
1131
+ },
1132
+ reloadSuccess: () => {
1133
+ this.reloadSuccess()
1134
+ },
1135
+ enterOfflineMode: () => {
1136
+ this.offlineHandle()
1137
+ },
1138
+ reStartSDK: () => {
1139
+ this.reStartSession()
1140
+ },
1141
+ sendVoiceEnd: () => {
1142
+ this.renderScheduler?.sendVoiceEnd();
1143
+ },
1144
+ getCurrentFrameIndex: () => {
1145
+ return this.renderScheduler?.getCurrentFrameIndex()??0;
1146
+ }
1147
+ });
1148
+ }
1149
+
1150
+ protected start() {
1151
+ this.renderScheduler.render();
1152
+ this.ttsa?.start();
1153
+
1154
+ // 如果初始化时设置了隐身模式,则立即进入隐身状态
1155
+ if (this.pendingInvisibleMode) {
1156
+ this.pendingInvisibleMode = false;
1157
+ this.setInvisibleMode()
1158
+ } else {
1159
+ this.onStatusChange(AvatarStatus.online);
1160
+ }
1161
+
1162
+ }
1163
+
1164
+ protected onTtsaReady(_sessionInfo?: ISessionResponse) {
1165
+ this.start();
1166
+ }
1167
+
1168
+ private async _reload() {
1169
+ const res = await this.resourceManager._reload();
1170
+ if (res && !this.destroyed) {
1171
+ await this.onSessionReloaded(res);
1172
+ if (this.destroyed) {
1173
+ return null;
1174
+ }
1175
+ this.ttsa = this.connectTtsa(res);
1176
+ this.ttsa._setResumeInfoCallback(
1177
+ this.resourceManager._getSessionId(),
1178
+ this.renderScheduler._getResumeInfo.bind(this.renderScheduler)
1179
+ )
1180
+ }
1181
+ return res;
1182
+ }
1183
+
1184
+ protected async onSessionReloaded(_sessionInfo: ISessionResponse): Promise<void> {}
1185
+
1186
+ protected onSessionReloadExhausted(_error?: unknown): void {}
1187
+
1188
+ protected async restartSessionForTransport(reason: string): Promise<void> {
1189
+ this.renderScheduler.interrupt("in_offline_mode");
1190
+ await this.resourceManager.stopSession(reason);
1191
+ if (this.destroyed) {
1192
+ return;
1193
+ }
1194
+ this.ttsa?.close();
1195
+ if (this.status !== AvatarStatus.offline) {
1196
+ this.offlineHandle();
1197
+ }
1198
+ this.reStartSession();
1199
+ }
1200
+
1201
+ protected cancelSessionRestart(): void {
1202
+ this.resetRetryState();
1203
+ this.clearAllRetryTimers();
1204
+ }
1205
+
1206
+ reloadSuccess() {
1207
+ this.onStatusChange(AvatarStatus.online);
1208
+ setTimeout(() => {
1209
+ window.clearInterval(this._offlineTimer)
1210
+ }, 1000)
1211
+ }
1212
+
1213
+ protected async stop() {
1214
+ await this.renderScheduler.stop();
1215
+ }
1216
+
1217
+ private handleBeforeUnload = () => {
1218
+ this.destroy("beforeunload");
1219
+ };
1220
+
1221
+ public isDestroyed() {
1222
+ return this.destroyed;
1223
+ }
1224
+
1225
+ public async destroyClient() {
1226
+ // 增加重复销毁检查,避免重复销毁导致的异常
1227
+ if (this.destroyed) {
1228
+ (window as any).avatarSDKLogger.warn(this.TAG, "SDK 已销毁,忽略重复销毁请求");
1229
+ return;
1230
+ }
1231
+ this.destroyed = true;
1232
+
1233
+ // 薄客户端模式:清理薄客户端相关资源
1234
+ if (this._isClientMode) {
1235
+ if (this._streamingClient) {
1236
+ this._streamingClient.destroy();
1237
+ this._streamingClient = null;
1238
+ }
1239
+ if (this._videoPlayer) {
1240
+ this._videoPlayer.destroy();
1241
+ this._videoPlayer = null;
1242
+ }
1243
+ this._eventDispatcher = null;
1244
+ this._apiForwarder = null;
1245
+ this.el = null;
1246
+ window.removeEventListener("beforeunload", this.handleBeforeUnload);
1247
+ (window as any).avatarSDKLogger.log(this.TAG, "薄客户端 SDK 已销毁");
1248
+ return;
1249
+ }
1250
+
1251
+ this.cancelSessionRestart();
1252
+
1253
+ // 清理离线模式定时器
1254
+ if (this._offlineTimer !== -1) {
1255
+ clearInterval(this._offlineTimer);
1256
+ this._offlineTimer = -1;
1257
+ }
1258
+
1259
+ // 停止TTSA连接
1260
+ if (this.ttsa) {
1261
+ this.ttsa.close();
1262
+ this.ttsa = null;
1263
+ }
1264
+
1265
+ // 停止渲染调度器
1266
+ if (this.renderScheduler) {
1267
+ this.renderScheduler.destroy();
1268
+ }
1269
+
1270
+ // 清理资源管理器(包括缓存)
1271
+ if (this.resourceManager) {
1272
+ this.resourceManager.destroy();
1273
+ }
1274
+
1275
+ // 清理调试浮层
1276
+ if (this.debugOverlay) {
1277
+ this.debugOverlay.destroy();
1278
+ this.debugOverlay = null;
1279
+ }
1280
+ // 主动断开需要销毁网络监听相关事件
1281
+ // 增加状态检查和异常捕获,避免重复销毁 networkMonitor
1282
+ if(this.networkMonitor) {
1283
+ try {
1284
+ this.networkMonitor.destroy();
1285
+ this.networkMonitor = null; // 标记已销毁
1286
+ } catch (e) {
1287
+ (window as any).avatarSDKLogger.warn(this.TAG, '销毁 networkMonitor 时出错', e);
1288
+ }
1289
+ }
1290
+
1291
+ // 清理音频捕获资源
1292
+ this.pcmEncodeId++;
1293
+ this.captureGeneration++;
1294
+ this.restartPlaybackCaptureImpl = null;
1295
+ if (this.captureWorklet) {
1296
+ try {
1297
+ this.captureWorklet.port.onmessage = null;
1298
+ this.captureWorklet.disconnect();
1299
+ } catch {}
1300
+ this.captureWorklet = null;
1301
+ }
1302
+ if (this.captureSource) {
1303
+ try {
1304
+ this.captureSource.disconnect();
1305
+ } catch {}
1306
+ this.captureSource = null;
1307
+ }
1308
+ if (this.captureAudioCtx) {
1309
+ try {
1310
+ this.captureAudioCtx.close();
1311
+ } catch {}
1312
+ this.captureAudioCtx = null;
1313
+ }
1314
+
1315
+ // 清理 PCM 捕获资源
1316
+ const audioPlayer = this.renderScheduler?.getAudioRenderer()?.getAudioPlayer();
1317
+ if (audioPlayer) {
1318
+ audioPlayer.detachCapture();
1319
+ }
1320
+ void this.pcmWebMEncoder?.destroy();
1321
+ this.pcmWebMEncoder = null;
1322
+ this.mediaRecorderEncoder?.destroy();
1323
+ this.mediaRecorderEncoder = null;
1324
+ this.pcmCaptureLastSpeechId = -2;
1325
+ this.pcmCaptureFirstChunk = true;
1326
+ this.pcmCaptureMode = 'none';
1327
+
1328
+ // 清理DOM元素
1329
+ this.el = null;
1330
+ this.startConnectTime = 0;
1331
+ this.connectSuccessTime = 0;
1332
+ this.sessionResTime = 0;
1333
+ this.charbinResTime = 0;
1334
+ this.isfirstReceive = true;
1335
+ this.networkInfo = {
1336
+ rtt: 0,
1337
+ downlink: 0.0,
1338
+ networkStatus: false,
1339
+ };
1340
+
1341
+ window.removeEventListener("beforeunload", this.handleBeforeUnload);
1342
+ document.removeEventListener("visibilitychange", this.boundVisibilityChange);
1343
+
1344
+ (window as any).avatarSDKLogger.log(this.TAG, "SDK 已销毁");
1345
+ }
1346
+
1347
+ public async destroy(stop_reason: string = "user"): Promise<void> {
1348
+ this.replayData = null;
1349
+
1350
+ // 薄客户端模式:通知 server 端销毁,然后清理本地资源
1351
+ if (this._isClientMode) {
1352
+ this._apiForwarder?.destroy(stop_reason);
1353
+ this.destroyClient();
1354
+ this.onStatusChange(AvatarStatus.close);
1355
+ return;
1356
+ }
1357
+
1358
+ this.ttsa?.sendSdkPoint('close_session', {
1359
+ reason: stop_reason,
1360
+ });
1361
+ this.destroyClient();
1362
+ const res = await this.resourceManager.stopSession(stop_reason);
1363
+ if(res) {
1364
+ this.onStatusChange(AvatarStatus.close);
1365
+ }
1366
+ return res
1367
+ }
1368
+
1369
+ idle() {
1370
+ if (this._isClientMode) { this._apiForwarder?.idle(); return; }
1371
+ if(this.enableClientInterrupt) {
1372
+ this.renderScheduler.interrupt("idle");
1373
+ }
1374
+ this.ttsa?.idle();
1375
+ }
1376
+
1377
+ listen() {
1378
+ if (this._isClientMode) { this._apiForwarder?.listen(); return; }
1379
+ if(this.enableClientInterrupt) {
1380
+ this.renderScheduler.interrupt("listen");
1381
+ }
1382
+ this.ttsa?.listen();
1383
+ }
1384
+
1385
+ think() {
1386
+ if (this._isClientMode) { this._apiForwarder?.think(); return; }
1387
+ if(this.enableClientInterrupt) {
1388
+ this.renderScheduler.interrupt("think");
1389
+ }
1390
+ this.ttsa?.think();
1391
+ }
1392
+ interactiveidle() {
1393
+ if (this._isClientMode) { this._apiForwarder?.interactiveidle(); return; }
1394
+ if(this.enableClientInterrupt) {
1395
+ this.renderScheduler.interrupt("interactiveidle");
1396
+ }
1397
+ this.ttsa?.interactiveidle();
1398
+ }
1399
+
1400
+ speak(ssml: string, is_start: boolean = true, is_end: boolean = true, extra = {client_speak_id: '', client_frame: 0}): string | undefined{
1401
+ if (this._isClientMode) { this._apiForwarder?.speak(ssml, is_start, is_end, extra); return; }
1402
+ if(this.enableClientInterrupt && is_start) {
1403
+ this.renderScheduler.interrupt("speak");
1404
+ }
1405
+ extra.client_frame = this.renderScheduler?.getCurrentFrameIndex()??0;
1406
+ const uniqueSpeakId = this.ttsa?.sendText(ssml, is_start, is_end, extra);
1407
+ this.renderScheduler.resume();
1408
+ return uniqueSpeakId;
1409
+ }
1410
+
1411
+ setOffsetFrame(offsetFrame: number) {
1412
+ this.renderScheduler.setOffsetFrame(offsetFrame);
1413
+ }
1414
+
1415
+ // 主动同步时间戳
1416
+ syncTimestamp() {
1417
+ this.ttsa?.sendSyncTimestamp(this.renderScheduler?.getCurrentFrameIndex()??0);
1418
+ }
1419
+
1420
+ /**
1421
+ * 通知后端进入隐身模式
1422
+ */
1423
+ private notifyEnterInvisibleMode(): void {
1424
+ this.ttsa?.enterInvisibleMode();
1425
+ }
1426
+
1427
+ /**
1428
+ * 通知后端退出隐身模式
1429
+ */
1430
+ private notifyExitInvisibleMode(): void {
1431
+ this.ttsa?.exitInvisibleMode();
1432
+ }
1433
+
1434
+ setVolume(volume: number) {
1435
+ if (this._isClientMode) { this._apiForwarder?.setVolume(volume); return; }
1436
+ this.renderScheduler.setVolume(volume);
1437
+ }
1438
+
1439
+ getSessionId() {
1440
+ if (this._isClientMode) {
1441
+ return this._clientSessionId;
1442
+ }
1443
+ return this.resourceManager?.session_id;
1444
+ }
1445
+
1446
+ // 显示调试浮层
1447
+ public showDebugInfo() {
1448
+ if (!this.debugOverlay) {
1449
+ return;
1450
+ }
1451
+ this.debugOverlay?.show();
1452
+ }
1453
+
1454
+ // 隐藏调试浮层
1455
+ public hideDebugInfo() {
1456
+ this.debugOverlay?.hide();
1457
+ }
1458
+
1459
+ // 改变数字人canvas的显隐状态
1460
+ public changeAvatarVisible(visible: boolean) {
1461
+ this.avatarCanvasVisible = visible;
1462
+ if (this._isClientMode) { this._apiForwarder?.changeAvatarVisible(visible); return; }
1463
+ this.renderScheduler?.setAvatarCanvasVisible(visible);
1464
+ }
1465
+
1466
+ /**
1467
+ * 在初始化未完成时清理所有已初始化的资源
1468
+ * @param reason 清理原因
1469
+ */
1470
+ protected cleanupBeforeInitComplete(reason: string): Promise<void> {
1471
+ if (this.incompleteInitializationCleanupPromise) {
1472
+ return this.incompleteInitializationCleanupPromise;
1473
+ }
1474
+
1475
+ (window as any).avatarSDKLogger.warn(this.TAG, `清理初始化未完成的资源,原因: ${reason}`);
1476
+ this.isInitialized = false;
1477
+ const stopSessionPromise = this.resourceManager?.stopSession(reason);
1478
+ void stopSessionPromise?.catch((error) => {
1479
+ (window as any).avatarSDKLogger.warn(this.TAG, '停止初始化未完成的会话时出错', error);
1480
+ });
1481
+ this.incompleteInitializationCleanupPromise = (async () => {
1482
+ try {
1483
+ await this.destroyClient();
1484
+ } catch (error) {
1485
+ (window as any).avatarSDKLogger.warn(this.TAG, '清理初始化未完成的本地资源时出错', error);
1486
+ }
1487
+ })();
1488
+ return this.incompleteInitializationCleanupPromise;
1489
+ }
1490
+
1491
+ protected reportInitializationFailure(message: string, cause?: unknown) {
1492
+ this.onMessage({
1493
+ code: EErrorCode.INIT_ERROR,
1494
+ message,
1495
+ e: cause as any,
1496
+ });
1497
+ }
1498
+
1499
+ private offlineHandle() {
1500
+ this.onStatusChange(AvatarStatus.offline)
1501
+ this.renderScheduler._offlineMode()
1502
+ this._offlineTimer = window.setInterval(() => {
1503
+ if(this.status !== AvatarStatus.offline) {
1504
+ clearInterval(this._offlineTimer);
1505
+ this._offlineTimer = -1;
1506
+ return
1507
+ }
1508
+ this.renderScheduler._offlineRun()
1509
+ }, this._offlineInterval)
1510
+ }
1511
+
1512
+ public async stopSessionFromSocket(reason: string) {
1513
+ await this.resourceManager.stopSession(reason)
1514
+ }
1515
+
1516
+ offlineMode() {
1517
+ // this.networkMonitor.setState(false)
1518
+ this.renderScheduler.interrupt("in_offline_mode")
1519
+ this.resourceManager.stopSession("offline_mode")
1520
+ this.ttsa?.close()
1521
+ this.offlineHandle()
1522
+ }
1523
+
1524
+ onlineMode() {
1525
+ if (!NetworkMonitor.ONLINE) {
1526
+ return
1527
+ }
1528
+ // this.networkMonitor.setState(true)
1529
+ this._reload()
1530
+ }
1531
+
1532
+ onMessage(params: Parameters<typeof Errors>[0]) {
1533
+ // 添加 session_id
1534
+ try {
1535
+ if (typeof params.e !== 'object' || params.e === null) {
1536
+ params.e = { msg: params.e };
1537
+ }
1538
+ params.session_id = this.getSessionId();
1539
+ } catch (e) {
1540
+ (window as any).avatarSDKLogger.warn(this.TAG, '获取 session_id 时出错', e);
1541
+ }
1542
+ const e = Errors(params)
1543
+ if(params.code !== EErrorCode.RENDER_BODY_ERROR && params.code !== EErrorCode.RENDER_FACE_ERROR && params.code !== EErrorCode.BODY_DATA_EXPIRED) {
1544
+ this.options.onMessage(e)
1545
+ }
1546
+ this.ttsa?.send("client_error", params);
1547
+ this.debugOverlay?.addError(e)
1548
+ }
1549
+
1550
+ onStatusChange(status: AvatarStatus) {
1551
+ if(status === this.status) {
1552
+ return
1553
+ }
1554
+ this.status = status
1555
+ this.options.onStatusChange?.(status)
1556
+ }
1557
+
1558
+ /**
1559
+ * 切换隐身模式(暂停/恢复音视频实时渲染)
1560
+ */
1561
+ public switchInvisibleMode(): void {
1562
+ if (this._isClientMode) { this._apiForwarder?.switchInvisibleMode(); return; }
1563
+ if (!this.renderScheduler) {
1564
+ (window as any).avatarSDKLogger?.warn(
1565
+ this.TAG,
1566
+ 'RenderScheduler 未初始化,无法切换隐身模式'
1567
+ );
1568
+ return;
1569
+ }
1570
+
1571
+ // 如果当前数字人状态是在线 或者 visible,切换到暂停时
1572
+ if (this.status === AvatarStatus.visible || this.status === AvatarStatus.online ) {
1573
+ this.setInvisibleMode()
1574
+ } else {
1575
+ this.notifyExitInvisibleMode();
1576
+ this.renderScheduler.switchInvisibleMode();
1577
+ this.listen();
1578
+ }
1579
+ }
1580
+ private setInvisibleMode() {
1581
+ // 先调用 interactiveidle()
1582
+ this.interactiveidle();
1583
+ this.renderScheduler.switchInvisibleMode();
1584
+ this.notifyEnterInvisibleMode();
1585
+ this.onStatusChange(AvatarStatus.invisible);
1586
+ }
1587
+ public getPendingInvisibleMode() {
1588
+ return this.pendingInvisibleMode || false;
1589
+ }
1590
+ /**
1591
+ * 获取当前渲染状态
1592
+ */
1593
+ public getRenderState(): RenderState {
1594
+ if (!this.renderScheduler) {
1595
+ return RenderState.init;
1596
+ }
1597
+ return this.renderScheduler.getRenderState();
1598
+ }
1599
+
1600
+ changeLayout(layout: Layout) {
1601
+ if (this._isClientMode) { this._apiForwarder?.changeLayout(layout); return; }
1602
+ const {scale = 1} = layout.avatar || {}
1603
+ layout.avatar.scale = validateScaleWithDecimal(scale, "changeLayout") as number | string;
1604
+ this.ttsa?.changeLayout(layout);
1605
+ this.renderScheduler.setCharacterCanvasLayout(layout);
1606
+ }
1607
+
1608
+ changeWalkConfig(walkConfig: WalkConfig) {
1609
+ if (this._isClientMode) { this._apiForwarder?.changeWalkConfig(walkConfig); return; }
1610
+ this.ttsa?.changeWalkConfig(walkConfig)
1611
+ this.renderScheduler.setWalkConfig(walkConfig);
1612
+ }
1613
+
1614
+ interrupt(type: string):number {
1615
+ if (this._isClientMode) { this._apiForwarder?.interrupt(type); return 0; }
1616
+ return this.renderScheduler.interrupt(type)
1617
+ }
1618
+
1619
+ private triggerReconnect() {
1620
+ // 如果已经在重连中,直接返回,避免重复触发
1621
+ if (this.isRetrying) {
1622
+ (window as any).avatarSDKLogger.warn(this.TAG, "正在重连中,跳过本次触发");
1623
+ return;
1624
+ }
1625
+
1626
+ if (this.reconnectDebounceTimer !== -1) {
1627
+ clearTimeout(this.reconnectDebounceTimer);
1628
+ this.reconnectDebounceTimer = -1;
1629
+ }
1630
+ if (this.retryTimer !== -1) {
1631
+ clearTimeout(this.retryTimer);
1632
+ this.retryTimer = -1;
1633
+ }
1634
+
1635
+ this.reconnectDebounceTimer = window.setTimeout(() => {
1636
+ this.isRetrying = false;
1637
+ this.retryCount = 1;
1638
+ this.retryRound = 1;
1639
+ this.reStartSession();
1640
+ this.reconnectDebounceTimer = -1;
1641
+ }, 100);
1642
+ }
1643
+
1644
+ reStartSession() {
1645
+ if (this.isRetrying || this.retryRound > this.maxRetryRound) return;
1646
+
1647
+ this.clearAllRetryTimers();
1648
+
1649
+ // 标记重试状态
1650
+ this.isStartRetry = true;
1651
+ this.isRetrying = true;
1652
+
1653
+ // 使用非递归方式实现重试逻辑
1654
+ this._retryImpl();
1655
+ }
1656
+
1657
+ private async _retryImpl() {
1658
+ if (this.retryRound > this.maxRetryRound || !this.isRetrying) {
1659
+ return;
1660
+ }
1661
+
1662
+ let delay = 1000;
1663
+ if (this.retryCount > 1) {
1664
+ delay = Math.pow(2, this.retryCount) * 1000;
1665
+ }
1666
+
1667
+ // 使用setTimeout代替递归,避免调用栈溢出
1668
+ setTimeout(async () => {
1669
+ if (!this.isRetrying || this.destroyed) {
1670
+ return;
1671
+ }
1672
+ try {
1673
+ const result = await this._reload();
1674
+ if (!this.isRetrying || this.destroyed) {
1675
+ return;
1676
+ }
1677
+ if (result === null) {
1678
+ // 重试失败:判断本轮是否达上限
1679
+ if (this.retryCount >= this.maxRetryCount) {
1680
+ // 本轮次数用尽,判断是否达最大轮次
1681
+ if (this.retryRound >= this.maxRetryRound) {
1682
+ // 总次数用尽(3*9=27次),停止重试
1683
+ (window as any).avatarSDKLogger.warn(
1684
+ this.TAG,
1685
+ `重试达最大轮次(${this.maxRetryRound})+每轮最大次数(${this.maxRetryCount}),总次数=${this.maxRetryRound*this.maxRetryCount},停止重试`
1686
+ );
1687
+ this.onSessionReloadExhausted();
1688
+ this.resetRetryState(); // 完全重置状态
1689
+ return;
1690
+ } else {
1691
+ // 未达最大轮次:重置本轮计数,轮次+1
1692
+ (window as any).avatarSDKLogger.warn(
1693
+ this.TAG,
1694
+ `第${this.retryRound}轮重试次数达上限(${this.maxRetryCount}),进入第${this.retryRound+1}轮重试`
1695
+ );
1696
+ this.retryCount = 1; // 重置本轮计数
1697
+ this.retryRound += 1; // 轮次+1
1698
+ }
1699
+ } else {
1700
+ // 本轮未达上限:计数+1
1701
+ this.retryCount += 1;
1702
+ }
1703
+ // 继续重试
1704
+ this._retryImpl();
1705
+ } else {
1706
+ // 重试成功:完全重置所有状态
1707
+ this.renderScheduler.stopAudio(-1);
1708
+ (window as any).avatarSDKLogger.log(
1709
+ this.TAG,
1710
+ `第${this.retryRound}轮第${this.retryCount}次重试成功,停止重试`
1711
+ );
1712
+ this.resetRetryState();
1713
+ }
1714
+ } catch (error) {
1715
+ // 异常处理:逻辑同失败场景
1716
+ (window as any).avatarSDKLogger.error(this.TAG, "重试过程异常", error);
1717
+ if (this.retryCount >= this.maxRetryCount) {
1718
+ if (this.retryRound >= this.maxRetryRound) {
1719
+ (window as any).avatarSDKLogger.warn(
1720
+ this.TAG,
1721
+ `重试异常且达总次数上限(${this.maxRetryRound*this.maxRetryCount}),停止重试`
1722
+ );
1723
+ this.onSessionReloadExhausted(error);
1724
+ this.resetRetryState();
1725
+ return;
1726
+ } else {
1727
+ this.retryCount = 1;
1728
+ this.retryRound += 1;
1729
+ }
1730
+ } else {
1731
+ this.retryCount += 1;
1732
+ }
1733
+ // 仅当未达最大轮次时,继续重试
1734
+ if (this.retryRound <= this.maxRetryRound && this.isRetrying) {
1735
+ this._retryImpl();
1736
+ } else {
1737
+ this.resetRetryState();
1738
+ }
1739
+ }
1740
+ }, delay);
1741
+ }
1742
+ private clearAllRetryTimers() {
1743
+ // 清理重连防抖定时器
1744
+ if (this.reconnectDebounceTimer !== -1) {
1745
+ clearTimeout(this.reconnectDebounceTimer);
1746
+ this.reconnectDebounceTimer = -1;
1747
+ }
1748
+ // 清理重试定时器
1749
+ if (this.retryTimer !== -1) {
1750
+ clearTimeout(this.retryTimer);
1751
+ this.retryTimer = -1;
1752
+ }
1753
+ }
1754
+
1755
+ // 统一重置重试状态
1756
+ private resetRetryState() {
1757
+ this.isRetrying = false;
1758
+ this.isStartRetry = false;
1759
+ this.retryCount = 1; // 重置每轮计数
1760
+ this.retryRound = 1; // 重置轮次(关键)
1761
+ }
1762
+
1763
+ public seekToFrame(frame: number) {
1764
+ this.renderScheduler.seekToFrame(frame);
1765
+ this.ttsa?.seekToFrame(frame);
1766
+ }
1767
+
1768
+ public async startRecoder() {
1769
+ try {
1770
+ const canvas = this.el?.querySelector('canvas') as HTMLCanvasElement;
1771
+ const audio = this.el?.querySelector('audio') as HTMLAudioElement;
1772
+
1773
+ if (!canvas || !audio) {
1774
+ console.error('找不到 canvas 或 audio 元素');
1775
+ return;
1776
+ }
1777
+
1778
+ // 画面流
1779
+ const canvasStream = canvas.captureStream(60);
1780
+
1781
+ // 获取音频流:
1782
+ // 如果 setupAudioCapture 已调用 createMediaElementSource,audio 元素的输出
1783
+ // 已被劫持到 Web Audio API,captureStream() 会拿到静音流。
1784
+ // 此时复用已有的 captureSource 节点,追加 MediaStreamDestination 获取音频。
1785
+ let audioTracks: MediaStreamTrack[] = [];
1786
+
1787
+ if (this.captureSource && this.captureAudioCtx) {
1788
+ // 复用 setupAudioCapture 的音频图,追加录制专用输出节点
1789
+ const recorderDestination = this.captureAudioCtx.createMediaStreamDestination();
1790
+ this.captureSource.connect(recorderDestination);
1791
+ audioTracks = recorderDestination.stream.getAudioTracks();
1792
+ } else if (typeof (audio as any).captureStream === 'function') {
1793
+ // audio 未被劫持,直接用 captureStream
1794
+ audioTracks = (audio as any).captureStream().getAudioTracks();
1795
+ } else if (typeof (audio as any).mozCaptureStream === 'function') {
1796
+ audioTracks = (audio as any).mozCaptureStream().getAudioTracks();
1797
+ } else {
1798
+ (window as any).avatarSDKLogger.error('❌ 当前浏览器不支持 captureStream,无法录制音频');
1799
+ return;
1800
+ }
1801
+
1802
+ // 合并流
1803
+ this.stream = new MediaStream([
1804
+ ...canvasStream.getVideoTracks(),
1805
+ ...audioTracks
1806
+ ]);
1807
+
1808
+ // 清空上一次数据
1809
+ this.recordedChunks = [];
1810
+
1811
+ // 创建录制器
1812
+ this.mediaRecorder = new MediaRecorder(this.stream, {
1813
+ mimeType: 'video/mp4',
1814
+ videoBitsPerSecond: 12 * 1024 * 1024, // 12Mbps 超清
1815
+ });
1816
+
1817
+ // 数据收集
1818
+ this.mediaRecorder.ondataavailable = (e) => {
1819
+ if (e.data && e.data.size > 0) {
1820
+ this.recordedChunks.push(e.data);
1821
+ }
1822
+ };
1823
+
1824
+ // 开始录制
1825
+ this.mediaRecorder.start(100); // 100ms 收集一次,避免丢数据
1826
+
1827
+ (window as any).avatarSDKLogger.log('✅ 开始录制');
1828
+ } catch (err) {
1829
+ (window as any).avatarSDKLogger.error('❌ 录制失败:', err);
1830
+ }
1831
+ }
1832
+
1833
+ public stopRecoder() {
1834
+ if (!this.mediaRecorder) return;
1835
+ this.mediaRecorder.stop();
1836
+ this.stream?.getTracks().forEach(track => track.stop());
1837
+ (window as any).avatarSDKLogger.log('⏹ 停止录制');
1838
+ }
1839
+
1840
+ public exportVideo() {
1841
+ // 关键:检查有没有数据
1842
+ if (!this.recordedChunks.length) {
1843
+ (window as any).avatarSDKLogger.error('没有录制到数据');
1844
+ return;
1845
+ }
1846
+
1847
+ const blob = new Blob(this.recordedChunks, { type: 'video/mp4' });
1848
+ const url = URL.createObjectURL(blob);
1849
+
1850
+ const a = document.createElement('a');
1851
+ a.href = url;
1852
+ a.download = `录制_${new Date().getTime()}.mp4`;
1853
+ a.click();
1854
+ (window as any).avatarSDKLogger.log('✅ 下载成功,大小:', blob.size);
1855
+ }
1856
+
1857
+ public getOptions() {
1858
+ return this.options;
1859
+ }
1860
+
1861
+ // ── 测试用方法:模拟 WebGL 上下文丢失 ─────────────────────────────
1862
+
1863
+ /**
1864
+ * [测试用] 模拟 WebGL 上下文丢失
1865
+ * 用于测试分阶段恢复策略是否正常工作
1866
+ *
1867
+ * @param restoreAfterMs 自动恢复上下文的延迟时间(毫秒),默认 5000ms
1868
+ * @param forceFail 强制恢复失败(用于测试 medium/full 阶段的升级逻辑)
1869
+ *
1870
+ * @example
1871
+ * // 在浏览器控制台执行:
1872
+ * avatar.simulateContextLoss(5000); // 模拟丢失,5秒后自动恢复
1873
+ * avatar.simulateContextLoss(0, true); // 模拟丢失,不自动恢复(测试升级逻辑)
1874
+ */
1875
+ public simulateContextLoss(restoreAfterMs: number = 5000, forceFail: boolean = false): void {
1876
+ if (!this.renderScheduler) {
1877
+ (window as any).avatarSDKLogger.warn("[Test] renderScheduler not initialized");
1878
+ return;
1879
+ }
1880
+ this.renderScheduler.simulateContextLoss(restoreAfterMs, forceFail);
1881
+ }
1882
+
1883
+ /**
1884
+ * [测试用] 获取当前 WebGL 上下文恢复状态
1885
+ */
1886
+ public getRecoveryStatus() {
1887
+ if (!this.renderScheduler) {
1888
+ return null;
1889
+ }
1890
+ return this.renderScheduler.getRecoveryStatus();
1891
+ }
1892
+
1893
+ /**
1894
+ * [测试用] 批量测试分阶段恢复
1895
+ * 依次触发多次上下文丢失,观察各阶段的恢复情况
1896
+ */
1897
+ public async testStagedRecovery(): Promise<void> {
1898
+ if (!this.renderScheduler) {
1899
+ (window as any).avatarSDKLogger.warn("[Test] renderScheduler not initialized");
1900
+ return;
1901
+ }
1902
+ return this.renderScheduler.testStagedRecovery();
1903
+ }
1904
+
1905
+ /**
1906
+ * 手动重启 WebGL:创建新 canvas + 新 GLDevice + 新 GLPipeline
1907
+ * 用于分阶段恢复全部失败后的手动恢复
1908
+ * @returns 是否成功
1909
+ *
1910
+ * @example
1911
+ * const ok = avatar.restartWebGL();
1912
+ */
1913
+ public restartWebGL(): boolean {
1914
+ if (!this.renderScheduler) {
1915
+ (window as any).avatarSDKLogger.warn("[restartWebGL] renderScheduler not initialized");
1916
+ return false;
1917
+ }
1918
+ return this.renderScheduler.restartWebGL();
1919
+ }
1920
+ }