@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.
- package/README.md +139 -0
- package/dist/agent/__tests__/agent.test.d.ts +1 -0
- package/dist/agent/audio-debug.d.ts +3 -0
- package/dist/agent/audio-uplink.d.ts +62 -0
- package/dist/agent/avatar.cjs +2 -0
- package/dist/agent/avatar.cjs.map +1 -0
- package/dist/agent/avatar.modern.js +2 -0
- package/dist/agent/avatar.modern.js.map +1 -0
- package/dist/agent/avatar.module.js +2 -0
- package/dist/agent/avatar.module.js.map +1 -0
- package/dist/agent/avatar.umd.js +2 -0
- package/dist/agent/avatar.umd.js.map +1 -0
- package/dist/agent/e2e-client.d.ts +32 -0
- package/dist/agent/fixed-audio-track.d.ts +48 -0
- package/dist/agent/index.cjs +5 -0
- package/dist/agent/index.d.ts +104 -0
- package/dist/agent/index.umd.js +10 -0
- package/dist/agent/microphone.d.ts +49 -0
- package/dist/agent/types.d.ts +153 -0
- package/dist/baseRender/AudioRenderer.d.ts +46 -0
- package/dist/baseRender/AudioWorklet.d.ts +78 -0
- package/dist/baseRender/AvatarRenderer.d.ts +226 -0
- package/dist/baseRender/MSEAudioPlayer.d.ts +51 -0
- package/dist/baseRender/UIRenderer.d.ts +39 -0
- package/dist/baseRender/pcm-audio-processor.d.ts +63 -0
- package/dist/control/APIForwarder.d.ts +23 -0
- package/dist/control/DataCacheQueue.d.ts +69 -0
- package/dist/control/EventDispatcher.d.ts +35 -0
- package/dist/control/RenderScheduler.d.ts +162 -0
- package/dist/control/SaveAndDownload.d.ts +14 -0
- package/dist/control/StreamingClient.d.ts +33 -0
- package/dist/control/VideoPlayer.d.ts +47 -0
- package/dist/control/ttsa.d.ts +111 -0
- package/dist/encoding/media-recorder-encoder.d.ts +33 -0
- package/dist/encoding/pcm-webm-encoder.d.ts +13 -0
- package/dist/encoding/webcodec-opus-encoder.d.ts +41 -0
- package/dist/encoding/webm-muxer.d.ts +90 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +231 -0
- package/dist/index.modern.js +2 -0
- package/dist/index.modern.js.map +1 -0
- package/dist/index.module.js +2 -0
- package/dist/index.module.js.map +1 -0
- package/dist/index.umd.js +2 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/modules/Composition.d.ts +28 -0
- package/dist/modules/ResourceManager.d.ts +295 -0
- package/dist/modules/TrackRenderer/base-track.d.ts +4 -0
- package/dist/modules/TrackRenderer/index.d.ts +12 -0
- package/dist/modules/TrackRenderer/render-implements.d.ts +19 -0
- package/dist/modules/TrackRenderer/track-pic.d.ts +8 -0
- package/dist/modules/TrackRenderer/track-subtitle.d.ts +8 -0
- package/dist/modules/cache-manager.d.ts +5 -0
- package/dist/modules/decoder.d.ts +84 -0
- package/dist/modules/error-handle.d.ts +17 -0
- package/dist/modules/network.d.ts +21 -0
- package/dist/proto/face_data_pb.d.ts +0 -0
- package/dist/types/capability.d.ts +15 -0
- package/dist/types/error.d.ts +70 -0
- package/dist/types/event.d.ts +25 -0
- package/dist/types/frame-data.d.ts +107 -0
- package/dist/types/index.d.ts +187 -0
- package/dist/types/render.d.ts +18 -0
- package/dist/utils/DataInterface.d.ts +113 -0
- package/dist/utils/GLDevice.d.ts +46 -0
- package/dist/utils/GLPipeline.d.ts +84 -0
- package/dist/utils/GLPipelineDebugTools.d.ts +5 -0
- package/dist/utils/Math.d.ts +21 -0
- package/dist/utils/__tests__/capability-checker.test.d.ts +1 -0
- package/dist/utils/audio-session.d.ts +11 -0
- package/dist/utils/audio.d.ts +1 -0
- package/dist/utils/capability-checker.d.ts +23 -0
- package/dist/utils/encodeToken.d.ts +7 -0
- package/dist/utils/face.d.ts +1 -0
- package/dist/utils/float32-decoder.d.ts +26 -0
- package/dist/utils/fpsTracker.d.ts +27 -0
- package/dist/utils/index.d.ts +22 -0
- package/dist/utils/logger.d.ts +9 -0
- package/dist/utils/media-recorder-timestamp.d.ts +15 -0
- package/dist/utils/pcmAnalyzer.d.ts +40 -0
- package/dist/utils/perfermance.d.ts +15 -0
- package/dist/utils/request.d.ts +19 -0
- package/dist/utils/requestAnimateFrames.d.ts +31 -0
- package/dist/utils/time.d.ts +9 -0
- package/dist/view/DebugOverlay.d.ts +54 -0
- package/dist/worker/streaming-video.d.ts +1 -0
- package/package.json +82 -0
- package/src/agent/__tests__/agent.test.ts +3787 -0
- package/src/agent/audio-debug.ts +36 -0
- package/src/agent/audio-uplink.ts +392 -0
- package/src/agent/e2e-client.ts +215 -0
- package/src/agent/fixed-audio-track.ts +547 -0
- package/src/agent/index.ts +1393 -0
- package/src/agent/microphone.ts +534 -0
- package/src/agent/types.ts +197 -0
- package/src/baseRender/AudioRenderer.ts +317 -0
- package/src/baseRender/AudioWorklet.ts +776 -0
- package/src/baseRender/AvatarRenderer.ts +1559 -0
- package/src/baseRender/MSEAudioPlayer.ts +243 -0
- package/src/baseRender/UIRenderer.ts +195 -0
- package/src/baseRender/pcm-audio-processor.js +267 -0
- package/src/control/APIForwarder.ts +67 -0
- package/src/control/DataCacheQueue.ts +372 -0
- package/src/control/EventDispatcher.ts +106 -0
- package/src/control/RenderScheduler.ts +945 -0
- package/src/control/SaveAndDownload.js +77 -0
- package/src/control/StreamingClient.ts +138 -0
- package/src/control/VideoPlayer.ts +306 -0
- package/src/control/ttsa.ts +676 -0
- package/src/encoding/media-recorder-encoder.ts +175 -0
- package/src/encoding/pcm-webm-encoder.ts +72 -0
- package/src/encoding/webcodec-opus-encoder.ts +258 -0
- package/src/encoding/webm-muxer.ts +337 -0
- package/src/global.d.ts +105 -0
- package/src/index.ts +1920 -0
- package/src/modules/Composition.ts +79 -0
- package/src/modules/ResourceManager.ts +899 -0
- package/src/modules/TrackRenderer/base-track.ts +7 -0
- package/src/modules/TrackRenderer/index.ts +40 -0
- package/src/modules/TrackRenderer/render-implements.ts +269 -0
- package/src/modules/TrackRenderer/track-pic.ts +19 -0
- package/src/modules/TrackRenderer/track-subtitle.ts +16 -0
- package/src/modules/cache-manager.ts +10 -0
- package/src/modules/decoder.ts +515 -0
- package/src/modules/error-handle.ts +20 -0
- package/src/modules/network.ts +71 -0
- package/src/proto/face_data.proto +45 -0
- package/src/proto/face_data_pb.js +1297 -0
- package/src/proto/protobuf.min.js +8 -0
- package/src/types/capability.ts +23 -0
- package/src/types/error.ts +211 -0
- package/src/types/event.ts +38 -0
- package/src/types/frame-data.ts +121 -0
- package/src/types/index.ts +207 -0
- package/src/types/render.ts +18 -0
- package/src/utils/DataInterface.ts +746 -0
- package/src/utils/GLDevice.ts +286 -0
- package/src/utils/GLPipeline.ts +1293 -0
- package/src/utils/GLPipelineDebugTools.ts +174 -0
- package/src/utils/Math.ts +266 -0
- package/src/utils/__tests__/capability-checker.test.ts +159 -0
- package/src/utils/audio-session.ts +144 -0
- package/src/utils/audio.ts +44 -0
- package/src/utils/blueimp-md5.d.ts +3 -0
- package/src/utils/capability-checker.ts +379 -0
- package/src/utils/encodeToken.ts +100 -0
- package/src/utils/face.ts +29 -0
- package/src/utils/float32-decoder.js +130 -0
- package/src/utils/fpsTracker.ts +100 -0
- package/src/utils/index.ts +182 -0
- package/src/utils/logger.js +36 -0
- package/src/utils/media-recorder-timestamp.ts +79 -0
- package/src/utils/pcmAnalyzer.ts +154 -0
- package/src/utils/perfermance.ts +141 -0
- package/src/utils/request.ts +95 -0
- package/src/utils/requestAnimateFrames.ts +195 -0
- package/src/utils/time.ts +32 -0
- package/src/view/DebugOverlay.ts +404 -0
- 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
|
+
}
|