@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
@@ -0,0 +1,175 @@
1
+ /**
2
+ * MediaRecorder 降级编码器
3
+ * 对于不支持 WebCodecs Opus 但支持 MediaRecorder webm/opus 的平台(Safari 18.4+)
4
+ * 通过 16kHz AudioContext + PCMInjectorProcessor + MediaRecorder 实现 Opus 编码
5
+ */
6
+
7
+ export class MediaRecorderEncoder {
8
+ private audioCtx: AudioContext | null = null;
9
+ private workletNode: AudioWorkletNode | null = null;
10
+ private destination: MediaStreamAudioDestinationNode | null = null;
11
+ private recorder: MediaRecorder | null = null;
12
+ private isRecording = false;
13
+ private nextResolve: ((data: ArrayBuffer | null) => void) | null = null;
14
+ private dataQueue: ArrayBuffer[] = []; // 缓冲 ondataavailable 产生的数据
15
+
16
+ /**
17
+ * 检测当前浏览器是否支持 MediaRecorder webm/opus
18
+ */
19
+ static isSupported(): boolean {
20
+ if (typeof MediaRecorder === 'undefined') return false;
21
+ return MediaRecorder.isTypeSupported('audio/webm;codecs=opus');
22
+ }
23
+
24
+ /**
25
+ * 初始化:创建 16kHz AudioContext + 中转 Worklet + MediaRecorder
26
+ */
27
+ async init(): Promise<void> {
28
+ const AudioContextCtor = window.AudioContext || (window as any).webkitAudioContext;
29
+ this.audioCtx = new AudioContextCtor({ sampleRate: 16000 });
30
+
31
+ // PCM 注入处理器:接收主线程传入的 S16LE PCM 数据,输出到音频流
32
+ const workletCode = `
33
+ class PCMInjectorProcessor extends AudioWorkletProcessor {
34
+ constructor() {
35
+ super();
36
+ this.buffer = new Float32Array(0);
37
+ this.port.onmessage = (e) => {
38
+ if (e.data.type === 'pcm') {
39
+ const int16 = new Int16Array(e.data.pcm);
40
+ const float32 = new Float32Array(int16.length);
41
+ for (let i = 0; i < int16.length; i++) {
42
+ float32[i] = int16[i] / 32768;
43
+ }
44
+ const newBuffer = new Float32Array(this.buffer.length + float32.length);
45
+ newBuffer.set(this.buffer);
46
+ newBuffer.set(float32, this.buffer.length);
47
+ this.buffer = newBuffer;
48
+ }
49
+ };
50
+ }
51
+ process(inputs, outputs) {
52
+ const output = outputs[0][0];
53
+ if (!output) return true;
54
+ const len = Math.min(output.length, this.buffer.length);
55
+ for (let i = 0; i < len; i++) {
56
+ output[i] = this.buffer[i];
57
+ }
58
+ this.buffer = this.buffer.slice(len);
59
+ for (let i = len; i < output.length; i++) {
60
+ output[i] = 0;
61
+ }
62
+ return true;
63
+ }
64
+ }
65
+ registerProcessor('pcm-injector-processor', PCMInjectorProcessor);
66
+ `;
67
+ const blob = new Blob([workletCode], { type: 'application/javascript' });
68
+ const url = URL.createObjectURL(blob);
69
+ await this.audioCtx.audioWorklet.addModule(url);
70
+ URL.revokeObjectURL(url);
71
+
72
+ this.workletNode = new AudioWorkletNode(this.audioCtx, 'pcm-injector-processor');
73
+ this.destination = this.audioCtx.createMediaStreamDestination();
74
+ this.workletNode.connect(this.destination);
75
+
76
+ // 创建 MediaRecorder
77
+ const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
78
+ ? 'audio/webm;codecs=opus'
79
+ : 'audio/webm';
80
+ this.recorder = new MediaRecorder(this.destination.stream, {
81
+ mimeType,
82
+ audioBitsPerSecond: 24000,
83
+ });
84
+
85
+ this.recorder.ondataavailable = (ev: BlobEvent) => {
86
+ if (ev.data && ev.data.size > 0) {
87
+ ev.data.arrayBuffer().then((buffer) => {
88
+ // 如果有等待中的 resolve,立即触发
89
+ if (this.nextResolve) {
90
+ const resolve = this.nextResolve;
91
+ this.nextResolve = null;
92
+ resolve(buffer);
93
+ } else {
94
+ // 否则加入队列缓冲
95
+ this.dataQueue.push(buffer);
96
+ }
97
+ });
98
+ }
99
+ };
100
+
101
+ // 恢复 AudioContext
102
+ if (this.audioCtx.state === 'suspended') {
103
+ await this.audioCtx.resume();
104
+ }
105
+ }
106
+
107
+ /**
108
+ * 注入 PCM 数据并等待编码输出
109
+ * @param pcmData S16LE PCM ArrayBuffer (16kHz)
110
+ * @returns 编码后的 WebM 数据,或 null
111
+ */
112
+ async feedPCM(pcmData: ArrayBuffer): Promise<ArrayBuffer | null> {
113
+ if (!this.workletNode || !this.recorder) return null;
114
+
115
+ // 启动录制(首次)
116
+ if (!this.isRecording) {
117
+ this.recorder.start(100);
118
+ this.isRecording = true;
119
+ }
120
+
121
+ // 发送 PCM 到 worklet
122
+ this.workletNode.port.postMessage({ type: 'pcm', pcm: pcmData }, [pcmData]);
123
+
124
+ // 如果队列有缓冲数据,立即返回
125
+ if (this.dataQueue.length > 0) {
126
+ return this.dataQueue.shift()!;
127
+ }
128
+
129
+ // 否则等待下一个 ondataavailable
130
+ return new Promise<ArrayBuffer | null>((resolve) => {
131
+ const timeout = setTimeout(() => {
132
+ this.nextResolve = null;
133
+ resolve(null);
134
+ }, 200); // 超时 200ms
135
+
136
+ this.nextResolve = (data) => {
137
+ clearTimeout(timeout);
138
+ resolve(data);
139
+ };
140
+ });
141
+ }
142
+
143
+ /**
144
+ * 重置(新 speak 段开始时调用)
145
+ */
146
+ reset(): void {
147
+ if (this.recorder && this.recorder.state !== 'inactive') {
148
+ this.recorder.stop();
149
+ }
150
+ this.isRecording = false;
151
+ this.nextResolve = null;
152
+ this.dataQueue = []; // 清空队列缓冲
153
+ }
154
+
155
+ destroy(): void {
156
+ this.nextResolve = null;
157
+ this.dataQueue = []; // 清空队列缓冲
158
+ if (this.recorder) {
159
+ try {
160
+ if (this.recorder.state !== 'inactive') {
161
+ this.recorder.stop();
162
+ }
163
+ } catch {}
164
+ }
165
+ this.workletNode?.disconnect();
166
+ this.destination?.disconnect();
167
+ try {
168
+ this.audioCtx?.close();
169
+ } catch {}
170
+ this.recorder = null;
171
+ this.workletNode = null;
172
+ this.destination = null;
173
+ this.audioCtx = null;
174
+ }
175
+ }
@@ -0,0 +1,72 @@
1
+ import { WebCodecOpusEncoder } from './webcodec-opus-encoder';
2
+ import { WebMMuxer } from './webm-muxer';
3
+
4
+ export class PcmWebMEncoder {
5
+ private readonly opusEncoder = new WebCodecOpusEncoder({
6
+ sampleRate: 16000,
7
+ channels: 1,
8
+ bitrate: 24000,
9
+ frameSize: 1600,
10
+ });
11
+ private readonly muxer = new WebMMuxer(16000);
12
+ private queue: Promise<void> = Promise.resolve();
13
+ private generation = 0;
14
+ private destroyed = false;
15
+ private pendingBlocks = 0;
16
+
17
+ static isSupported() {
18
+ return WebCodecOpusEncoder.isSupported();
19
+ }
20
+
21
+ async init() {
22
+ await this.opusEncoder.init();
23
+ }
24
+
25
+ async encode(pcm: Int16Array) {
26
+ if (pcm.length !== 1600) {
27
+ throw new Error(`PCM 音频块必须为 1600 samples,实际为 ${pcm.length}`);
28
+ }
29
+ if (this.pendingBlocks >= 20) {
30
+ throw new Error("PCM/Opus 编码队列超过 2 秒");
31
+ }
32
+ this.pendingBlocks += 1;
33
+ const generation = this.generation;
34
+ const pcmCopy = new Int16Array(pcm);
35
+ const operation = this.queue.then(async () => {
36
+ if (this.destroyed || generation !== this.generation) {
37
+ return new ArrayBuffer(0);
38
+ }
39
+ const frames = await this.opusEncoder.encode(pcmCopy);
40
+ if (this.destroyed || generation !== this.generation) {
41
+ return new ArrayBuffer(0);
42
+ }
43
+ for (const frame of frames) {
44
+ this.muxer.feedOpusFrame(frame.data, frame.durationMs);
45
+ }
46
+ return this.muxer.flush();
47
+ });
48
+ const trackedOperation = operation.finally(() => {
49
+ this.pendingBlocks -= 1;
50
+ });
51
+ this.queue = trackedOperation.then(() => undefined, () => undefined);
52
+ return trackedOperation;
53
+ }
54
+
55
+ reset() {
56
+ const generation = ++this.generation;
57
+ this.queue = this.queue.then(() => {
58
+ if (!this.destroyed && generation === this.generation) {
59
+ this.opusEncoder.reset();
60
+ this.muxer.reset();
61
+ }
62
+ });
63
+ }
64
+
65
+ async destroy() {
66
+ this.destroyed = true;
67
+ this.generation += 1;
68
+ await this.opusEncoder.destroy();
69
+ await this.queue;
70
+ this.muxer.reset();
71
+ }
72
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * WebCodecs AudioEncoder Opus 编码封装
3
+ * 使用浏览器原生 WebCodecs API 将 PCM 音频编码为 Opus 帧
4
+ */
5
+
6
+ export interface OpusEncoderConfig {
7
+ sampleRate: number; // 16000
8
+ channels: number; // 1 (mono)
9
+ bitrate: number; // 24000
10
+ frameSize: number; // 1600 (100ms @ 16kHz)
11
+ }
12
+
13
+ export interface EncodedOpusFrame {
14
+ data: Uint8Array;
15
+ durationMs: number;
16
+ }
17
+
18
+ const OPUS_FRAME_DURATION_US = 20_000;
19
+ const ENCODE_TIMEOUT_MS = 2_000;
20
+
21
+ interface PendingEncode {
22
+ startTimestampUs: number;
23
+ endTimestampUs: number;
24
+ expectedFrameCount: number;
25
+ chunks: EncodedAudioChunk[];
26
+ resolve(frames: EncodedOpusFrame[]): void;
27
+ reject(error: Error): void;
28
+ timeoutId: number;
29
+ }
30
+
31
+ function buildAudioEncoderConfig(config: OpusEncoderConfig) {
32
+ return {
33
+ codec: 'opus',
34
+ sampleRate: config.sampleRate,
35
+ numberOfChannels: config.channels,
36
+ bitrate: config.bitrate,
37
+ // Safari may otherwise choose a 120 ms Opus packet for a 100 ms AudioData
38
+ // input. The AU protocol clock is fixed at 100 ms, so pin packetization to
39
+ // five 20 ms packets per input block.
40
+ opus: {
41
+ format: 'opus' as const,
42
+ frameDuration: OPUS_FRAME_DURATION_US,
43
+ },
44
+ };
45
+ }
46
+
47
+ export class WebCodecOpusEncoder {
48
+ private encoder: AudioEncoder | null = null;
49
+ private config: OpusEncoderConfig;
50
+ private isInitialized = false;
51
+ private pendingEncode: PendingEncode | null = null;
52
+ private timestamp: number = 0; // 单位:微秒
53
+
54
+ constructor(config: Partial<OpusEncoderConfig> = {}) {
55
+ this.config = {
56
+ sampleRate: 16000,
57
+ channels: 1,
58
+ bitrate: 24000,
59
+ frameSize: 1600,
60
+ ...config,
61
+ };
62
+ }
63
+
64
+ /**
65
+ * 检测当前浏览器是否支持 WebCodecs Opus 编码
66
+ */
67
+ static async isSupported(): Promise<boolean> {
68
+ if (typeof AudioEncoder === 'undefined') return false;
69
+ try {
70
+ const support = await AudioEncoder.isConfigSupported(buildAudioEncoderConfig({
71
+ sampleRate: 16000,
72
+ channels: 1,
73
+ bitrate: 24000,
74
+ frameSize: 1600,
75
+ }));
76
+ return support.supported === true
77
+ && support.config?.opus?.frameDuration === OPUS_FRAME_DURATION_US;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ async init(): Promise<void> {
84
+ if (this.isInitialized) return;
85
+
86
+ this.encoder = this.createEncoder();
87
+
88
+ this.isInitialized = true;
89
+ }
90
+
91
+ /**
92
+ * 异步编码 PCM 为 Opus 帧
93
+ * @param pcm S16LE PCM 数据 (Int16Array, 1600 samples = 100ms)
94
+ * @returns 保留 packet 边界和时长的 Opus 帧数组
95
+ */
96
+ async encode(pcm: Int16Array): Promise<EncodedOpusFrame[]> {
97
+ if (!this.isInitialized || !this.encoder) {
98
+ return Promise.reject(new Error('WebCodecOpusEncoder 未初始化'));
99
+ }
100
+ if (this.pendingEncode) {
101
+ return Promise.reject(new Error('WebCodecs Opus 编码器仍在处理上一音频块'));
102
+ }
103
+
104
+ // 构造 AudioData(S16 格式)
105
+ // 复制为独立 ArrayBuffer,避免 SharedArrayBuffer 类型不兼容
106
+ // 同时确保数据边界精确(Int16Array 可能是更大 buffer 的 view)
107
+ const pcmCopy = new Int16Array(pcm);
108
+
109
+ const startTimestampUs = this.timestamp;
110
+ const blockDurationUs = Math.round((pcm.length * 1_000_000) / this.config.sampleRate);
111
+ const endTimestampUs = startTimestampUs + blockDurationUs;
112
+ const expectedFrameCount = blockDurationUs / OPUS_FRAME_DURATION_US;
113
+ if (!Number.isInteger(expectedFrameCount)) {
114
+ return Promise.reject(new Error('PCM 音频块无法按 20ms Opus packet 完整分包'));
115
+ }
116
+
117
+ const audioData = new AudioData({
118
+ format: 's16',
119
+ sampleRate: this.config.sampleRate,
120
+ numberOfFrames: pcmCopy.length,
121
+ numberOfChannels: this.config.channels,
122
+ timestamp: startTimestampUs,
123
+ data: pcmCopy,
124
+ });
125
+
126
+ const result = new Promise<EncodedOpusFrame[]>((resolve, reject) => {
127
+ const timeoutId = globalThis.setTimeout(() => {
128
+ this.failPending(new Error('WebCodecs Opus 编码输出超过 2 秒'));
129
+ }, ENCODE_TIMEOUT_MS);
130
+ this.pendingEncode = {
131
+ startTimestampUs,
132
+ endTimestampUs,
133
+ expectedFrameCount,
134
+ chunks: [],
135
+ resolve,
136
+ reject,
137
+ timeoutId,
138
+ };
139
+ });
140
+
141
+ try {
142
+ this.encoder.encode(audioData);
143
+ this.timestamp = endTimestampUs;
144
+ } catch (cause) {
145
+ this.failPending(cause instanceof Error ? cause : new Error('WebCodecs Opus 编码失败'));
146
+ } finally {
147
+ audioData.close();
148
+ }
149
+
150
+ return result;
151
+ }
152
+
153
+ /**
154
+ * 重置时间戳(新 speak 段开始时调用)
155
+ */
156
+ reset(): void {
157
+ this.timestamp = 0;
158
+ this.failPending(new Error('WebCodecs Opus 编码已重置'));
159
+ if (this.encoder) {
160
+ try {
161
+ this.encoder.close();
162
+ } catch {}
163
+ this.encoder = this.createEncoder();
164
+ }
165
+ }
166
+
167
+ async destroy(): Promise<void> {
168
+ const encoder = this.encoder;
169
+ if (encoder) {
170
+ try {
171
+ await encoder.flush();
172
+ } catch {}
173
+ this.failPending(new Error('WebCodecs Opus 编码器已销毁'));
174
+ try {
175
+ encoder.close();
176
+ } catch {}
177
+ }
178
+ this.encoder = null;
179
+ this.isInitialized = false;
180
+ }
181
+
182
+ private createEncoder() {
183
+ let encoder: AudioEncoder;
184
+ encoder = new AudioEncoder({
185
+ output: (chunk) => {
186
+ if (this.encoder === encoder) {
187
+ this.handleOutput(chunk);
188
+ }
189
+ },
190
+ error: (cause) => {
191
+ if (this.encoder !== encoder) return;
192
+ const error = cause instanceof Error ? cause : new Error('WebCodecs Opus 编码失败');
193
+ const logger = (globalThis as typeof globalThis & {
194
+ avatarSDKLogger?: Pick<Console, 'error'>;
195
+ }).avatarSDKLogger || console;
196
+ logger.error('[WebCodecOpusEncoder] 编码错误:', error);
197
+ this.failPending(error);
198
+ },
199
+ });
200
+ encoder.configure(buildAudioEncoderConfig(this.config));
201
+ return encoder;
202
+ }
203
+
204
+ private handleOutput(chunk: EncodedAudioChunk) {
205
+ const pending = this.pendingEncode;
206
+ if (!pending) {
207
+ // flush() may emit one codec-delay/padding packet after all real PCM input.
208
+ return;
209
+ }
210
+ if (
211
+ chunk.timestamp < pending.startTimestampUs
212
+ || chunk.timestamp >= pending.endTimestampUs
213
+ || (chunk.duration !== null && chunk.duration !== OPUS_FRAME_DURATION_US)
214
+ ) {
215
+ this.failPending(new Error('WebCodecs Opus 未按 20ms 分包,无法生成固定 100ms AU 音频'));
216
+ return;
217
+ }
218
+
219
+ pending.chunks.push(chunk);
220
+ if (pending.chunks.length > pending.expectedFrameCount) {
221
+ this.failPending(new Error('WebCodecs Opus 未按 20ms 分包,无法生成固定 100ms AU 音频'));
222
+ return;
223
+ }
224
+ if (pending.chunks.length < pending.expectedFrameCount) {
225
+ return;
226
+ }
227
+
228
+ const frames = pending.chunks.map((encodedChunk, index) => {
229
+ const data = new Uint8Array(encodedChunk.byteLength);
230
+ encodedChunk.copyTo(data);
231
+ const nextTimestamp = pending.chunks[index + 1]?.timestamp ?? pending.endTimestampUs;
232
+ const durationUs = encodedChunk.duration && encodedChunk.duration > 0
233
+ ? encodedChunk.duration
234
+ : Math.max(1, nextTimestamp - encodedChunk.timestamp);
235
+ return { data, durationMs: durationUs / 1000 };
236
+ });
237
+ const totalDurationMs = frames.reduce((total, frame) => total + frame.durationMs, 0);
238
+ if (
239
+ frames.some((frame) => frame.durationMs !== OPUS_FRAME_DURATION_US / 1000)
240
+ || totalDurationMs !== (pending.endTimestampUs - pending.startTimestampUs) / 1000
241
+ ) {
242
+ this.failPending(new Error('WebCodecs Opus 未按 20ms 分包,无法生成固定 100ms AU 音频'));
243
+ return;
244
+ }
245
+
246
+ globalThis.clearTimeout(pending.timeoutId);
247
+ this.pendingEncode = null;
248
+ pending.resolve(frames);
249
+ }
250
+
251
+ private failPending(error: Error) {
252
+ const pending = this.pendingEncode;
253
+ if (!pending) return;
254
+ globalThis.clearTimeout(pending.timeoutId);
255
+ this.pendingEncode = null;
256
+ pending.reject(error);
257
+ }
258
+ }