@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,36 @@
|
|
|
1
|
+
const DEFAULT_AUDIO_CHUNK_PREVIEW_BYTES = 64;
|
|
2
|
+
|
|
3
|
+
type AudioChunkDebugMetadata = Record<string, unknown>;
|
|
4
|
+
|
|
5
|
+
function toHex(byte: number) {
|
|
6
|
+
return byte.toString(16).padStart(2, "0");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function logAudioChunkContent(
|
|
10
|
+
label: string,
|
|
11
|
+
chunk: Blob | ArrayBuffer | ArrayBufferView,
|
|
12
|
+
metadata: AudioChunkDebugMetadata = {},
|
|
13
|
+
previewBytes = DEFAULT_AUDIO_CHUNK_PREVIEW_BYTES,
|
|
14
|
+
) {
|
|
15
|
+
try {
|
|
16
|
+
const buffer = chunk instanceof Blob
|
|
17
|
+
? await chunk.arrayBuffer()
|
|
18
|
+
: ArrayBuffer.isView(chunk)
|
|
19
|
+
? chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength)
|
|
20
|
+
: chunk;
|
|
21
|
+
const bytes = new Uint8Array(buffer);
|
|
22
|
+
const preview = Array.from(bytes.slice(0, previewBytes));
|
|
23
|
+
const type = chunk instanceof Blob ? chunk.type : metadata.type;
|
|
24
|
+
|
|
25
|
+
console.log(label, {
|
|
26
|
+
...metadata,
|
|
27
|
+
size: bytes.byteLength,
|
|
28
|
+
type,
|
|
29
|
+
previewLength: preview.length,
|
|
30
|
+
bytes: preview,
|
|
31
|
+
hex: preview.map(toHex).join(" "),
|
|
32
|
+
});
|
|
33
|
+
} catch (error) {
|
|
34
|
+
console.warn(label, "failed to read audio chunk", error);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
const AU_HEADER_BYTES = 8;
|
|
2
|
+
const AU_VERSION = 1;
|
|
3
|
+
const AUDIO_BLOCK_MS = 100;
|
|
4
|
+
const MAX_FAREND_BUCKET_SKEW = 1;
|
|
5
|
+
const MAX_PENDING_FAREND_BUCKETS = 3;
|
|
6
|
+
const NEAREND_GRACE_BUCKETS = 1;
|
|
7
|
+
const WEBM_EBML_MAGIC = new Uint8Array([0x1a, 0x45, 0xdf, 0xa3]);
|
|
8
|
+
|
|
9
|
+
export const AU_STREAM_NEAREND = 1 as const;
|
|
10
|
+
export const AU_STREAM_FAREND = 2 as const;
|
|
11
|
+
|
|
12
|
+
export type AuStreamId = typeof AU_STREAM_NEAREND | typeof AU_STREAM_FAREND;
|
|
13
|
+
export type AuPayload = Blob | ArrayBuffer | ArrayBufferView;
|
|
14
|
+
|
|
15
|
+
export interface AgentAudioBlock {
|
|
16
|
+
data: AuPayload;
|
|
17
|
+
/** Epoch timestamp for the beginning of this 100 ms media block. */
|
|
18
|
+
timestamp: number;
|
|
19
|
+
/** Whether this block starts a new WebM container stream. */
|
|
20
|
+
isFirstChunk?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface PendingAudioBlock extends AgentAudioBlock {
|
|
24
|
+
streamId: AuStreamId;
|
|
25
|
+
tsMs: number;
|
|
26
|
+
frameKey: number;
|
|
27
|
+
dataParts?: AuPayload[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface AgentAudioUplinkOptions {
|
|
31
|
+
sendBinary(frame: ArrayBuffer): void;
|
|
32
|
+
onError?(error: Error): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function logAudioUplink(event: string, details: Record<string, unknown>) {
|
|
36
|
+
const logger = (globalThis as typeof globalThis & {
|
|
37
|
+
avatarSDKLogger?: { log?: (...args: unknown[]) => void };
|
|
38
|
+
}).avatarSDKLogger;
|
|
39
|
+
logger?.log?.("[Agent][AudioUplink]", event, details);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function packAuAudioFrame(
|
|
43
|
+
streamId: AuStreamId,
|
|
44
|
+
tsMs: number,
|
|
45
|
+
payload: ArrayBuffer | ArrayBufferView,
|
|
46
|
+
): ArrayBuffer {
|
|
47
|
+
if (streamId !== AU_STREAM_NEAREND && streamId !== AU_STREAM_FAREND) {
|
|
48
|
+
throw new Error(`AU stream_id 无效: ${streamId}`);
|
|
49
|
+
}
|
|
50
|
+
if (!Number.isFinite(tsMs) || tsMs < 0 || tsMs > 0xffffffff) {
|
|
51
|
+
throw new Error(`AU ts_ms 无效: ${tsMs}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const body = payload instanceof ArrayBuffer
|
|
55
|
+
? new Uint8Array(payload)
|
|
56
|
+
: new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength);
|
|
57
|
+
const frame = new ArrayBuffer(AU_HEADER_BYTES + body.byteLength);
|
|
58
|
+
const view = new DataView(frame);
|
|
59
|
+
view.setUint8(0, 0x41);
|
|
60
|
+
view.setUint8(1, 0x55);
|
|
61
|
+
view.setUint8(2, AU_VERSION);
|
|
62
|
+
view.setUint8(3, streamId);
|
|
63
|
+
view.setUint32(4, Math.floor(tsMs), false);
|
|
64
|
+
new Uint8Array(frame, AU_HEADER_BYTES).set(body);
|
|
65
|
+
return frame;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function toPayloadBytes(payload: AuPayload | AuPayload[]): Promise<Uint8Array> {
|
|
69
|
+
if (Array.isArray(payload)) {
|
|
70
|
+
const chunks = await Promise.all(payload.map(toPayloadBytes));
|
|
71
|
+
const merged = new Uint8Array(chunks.reduce((size, chunk) => size + chunk.byteLength, 0));
|
|
72
|
+
let offset = 0;
|
|
73
|
+
for (const chunk of chunks) {
|
|
74
|
+
merged.set(chunk, offset);
|
|
75
|
+
offset += chunk.byteLength;
|
|
76
|
+
}
|
|
77
|
+
return merged;
|
|
78
|
+
}
|
|
79
|
+
if (payload instanceof Blob) {
|
|
80
|
+
return new Uint8Array(await payload.arrayBuffer());
|
|
81
|
+
}
|
|
82
|
+
if (payload instanceof ArrayBuffer) {
|
|
83
|
+
return new Uint8Array(payload);
|
|
84
|
+
}
|
|
85
|
+
return new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Owns the connection-level AU timeline and serializes nearend/farend writes.
|
|
90
|
+
* Nearend/farend are matched by 100 ms bucket. When independent capture clocks
|
|
91
|
+
* straddle a bucket boundary, an aged nearend may consume farend from
|
|
92
|
+
* the immediately preceding bucket. This keeps farend-before-nearend wire
|
|
93
|
+
* order without uploading farend-only buckets or dropping WebM continuations.
|
|
94
|
+
*/
|
|
95
|
+
export class AgentAudioUplink {
|
|
96
|
+
private readonly options: AgentAudioUplinkOptions;
|
|
97
|
+
private connectionStartedAt: number | null = null;
|
|
98
|
+
private connectionGeneration = 0;
|
|
99
|
+
private sendChain: Promise<void> = Promise.resolve();
|
|
100
|
+
private failed = false;
|
|
101
|
+
private readonly pendingFarend = new Map<number, PendingAudioBlock[]>();
|
|
102
|
+
private readonly pendingNearend = new Map<number, PendingAudioBlock[]>();
|
|
103
|
+
private readonly closedNearendBuckets = new Set<number>();
|
|
104
|
+
private readonly lastTimestampByStream = new Map<AuStreamId, number>();
|
|
105
|
+
private readonly lastSentTimestampByStream = new Map<AuStreamId, number>();
|
|
106
|
+
private readonly pendingWebmPrefixByStream = new Map<AuStreamId, Uint8Array>();
|
|
107
|
+
private farendStreamState: "unknown" | "ready" | "awaiting-header" = "unknown";
|
|
108
|
+
|
|
109
|
+
constructor(options: AgentAudioUplinkOptions) {
|
|
110
|
+
this.options = options;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
reset(connectionStartedAt: number | null = null) {
|
|
114
|
+
this.connectionGeneration += 1;
|
|
115
|
+
this.connectionStartedAt = connectionStartedAt;
|
|
116
|
+
this.pendingFarend.clear();
|
|
117
|
+
this.pendingNearend.clear();
|
|
118
|
+
this.closedNearendBuckets.clear();
|
|
119
|
+
this.lastTimestampByStream.clear();
|
|
120
|
+
this.lastSentTimestampByStream.clear();
|
|
121
|
+
this.pendingWebmPrefixByStream.clear();
|
|
122
|
+
this.farendStreamState = "unknown";
|
|
123
|
+
this.failed = false;
|
|
124
|
+
this.sendChain = Promise.resolve();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
startInput() {
|
|
128
|
+
this.failed = false;
|
|
129
|
+
this.pendingFarend.clear();
|
|
130
|
+
this.pendingNearend.clear();
|
|
131
|
+
this.closedNearendBuckets.clear();
|
|
132
|
+
this.pendingWebmPrefixByStream.clear();
|
|
133
|
+
this.farendStreamState = "unknown";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
clearFarend() {
|
|
137
|
+
this.pendingFarend.clear();
|
|
138
|
+
this.pendingWebmPrefixByStream.delete(AU_STREAM_FAREND);
|
|
139
|
+
this.farendStreamState = "unknown";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
offerFarend(block: AgentAudioBlock) {
|
|
143
|
+
if (block.isFirstChunk === false && this.farendStreamState !== "ready") {
|
|
144
|
+
logAudioUplink("farend.drop", {
|
|
145
|
+
reason: "awaiting_webm_header",
|
|
146
|
+
timestamp: block.timestamp,
|
|
147
|
+
isFirstChunk: false,
|
|
148
|
+
});
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const normalized = this.normalizeBlock(AU_STREAM_FAREND, block);
|
|
153
|
+
if (!normalized) {
|
|
154
|
+
if (block.isFirstChunk) {
|
|
155
|
+
this.abandonFarendStream();
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (block.isFirstChunk) {
|
|
161
|
+
this.pendingFarend.clear();
|
|
162
|
+
this.pendingWebmPrefixByStream.delete(AU_STREAM_FAREND);
|
|
163
|
+
this.farendStreamState = "ready";
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const bucket = this.pendingFarend.get(normalized.frameKey) ?? [];
|
|
167
|
+
bucket.push(normalized);
|
|
168
|
+
this.pendingFarend.set(normalized.frameKey, bucket);
|
|
169
|
+
logAudioUplink("farend.offer", {
|
|
170
|
+
tsMs: normalized.tsMs,
|
|
171
|
+
frameKey: normalized.frameKey,
|
|
172
|
+
isFirstChunk: Boolean(normalized.isFirstChunk),
|
|
173
|
+
pendingNearend: [...this.pendingNearend.keys()],
|
|
174
|
+
});
|
|
175
|
+
this.flushOlderNearend(normalized.frameKey);
|
|
176
|
+
this.prune(normalized.frameKey);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
offerNearend(block: AgentAudioBlock) {
|
|
180
|
+
const nearend = this.normalizeBlock(AU_STREAM_NEAREND, block);
|
|
181
|
+
if (!nearend || this.closedNearendBuckets.has(nearend.frameKey)) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const bucket = this.pendingNearend.get(nearend.frameKey) ?? [];
|
|
186
|
+
bucket.push(nearend);
|
|
187
|
+
this.pendingNearend.set(nearend.frameKey, bucket);
|
|
188
|
+
this.flushExpiredNearend(nearend.frameKey);
|
|
189
|
+
this.prune(nearend.frameKey);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
drain() {
|
|
193
|
+
for (const frameKey of [...this.pendingNearend.keys()].sort((a, b) => a - b)) {
|
|
194
|
+
this.flushBucket(frameKey, true);
|
|
195
|
+
}
|
|
196
|
+
return this.sendChain;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private normalizeBlock(streamId: AuStreamId, block: AgentAudioBlock): PendingAudioBlock | null {
|
|
200
|
+
const connectionStartedAt = this.connectionStartedAt;
|
|
201
|
+
if (connectionStartedAt === null || !Number.isFinite(block.timestamp)) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const relativeTimestamp = Math.max(0, Math.floor(block.timestamp - connectionStartedAt));
|
|
206
|
+
const frameKey = Math.floor(relativeTimestamp / AUDIO_BLOCK_MS);
|
|
207
|
+
const lastTimestamp = this.lastTimestampByStream.get(streamId);
|
|
208
|
+
if (lastTimestamp !== undefined && relativeTimestamp <= lastTimestamp) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
this.lastTimestampByStream.set(streamId, relativeTimestamp);
|
|
212
|
+
return { ...block, streamId, tsMs: relativeTimestamp, frameKey };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private enqueue(blocks: PendingAudioBlock[]) {
|
|
216
|
+
if (this.failed) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const generation = this.connectionGeneration;
|
|
220
|
+
this.sendChain = this.sendChain
|
|
221
|
+
.then(async () => {
|
|
222
|
+
if (this.failed) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
for (const block of blocks) {
|
|
226
|
+
const bytes = await toPayloadBytes(block.dataParts ?? block.data);
|
|
227
|
+
if (
|
|
228
|
+
this.failed
|
|
229
|
+
|| generation !== this.connectionGeneration
|
|
230
|
+
|| this.connectionStartedAt === null
|
|
231
|
+
|| bytes.byteLength === 0
|
|
232
|
+
) {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (block.streamId === AU_STREAM_FAREND && block.isFirstChunk) {
|
|
236
|
+
this.pendingWebmPrefixByStream.delete(AU_STREAM_FAREND);
|
|
237
|
+
}
|
|
238
|
+
const payload = this.coalesceSplitWebmMagic(block.streamId, bytes);
|
|
239
|
+
if (!payload) {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const lastSentTimestamp = this.lastSentTimestampByStream.get(block.streamId);
|
|
243
|
+
if (lastSentTimestamp !== undefined && block.tsMs <= lastSentTimestamp) {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
this.options.sendBinary(packAuAudioFrame(block.streamId, block.tsMs, payload));
|
|
247
|
+
this.lastSentTimestampByStream.set(block.streamId, block.tsMs);
|
|
248
|
+
logAudioUplink("frame.send", {
|
|
249
|
+
streamId: block.streamId,
|
|
250
|
+
tsMs: block.tsMs,
|
|
251
|
+
bytes: payload.byteLength,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
})
|
|
255
|
+
.catch((error) => {
|
|
256
|
+
if (generation !== this.connectionGeneration) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
this.failed = true;
|
|
260
|
+
this.pendingFarend.clear();
|
|
261
|
+
this.pendingNearend.clear();
|
|
262
|
+
const normalized = error instanceof Error ? error : new Error("AU 音频上行失败");
|
|
263
|
+
this.options.onError?.(normalized);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Chromium may emit the first WebM byte (0x1a) as its own MediaRecorder
|
|
269
|
+
* Blob and put the remaining EBML magic in the next Blob. A one-byte AU
|
|
270
|
+
* payload is not a valid timed audio block, so keep an incomplete WebM
|
|
271
|
+
* magic prefix and prepend it to the next block from the same stream.
|
|
272
|
+
*/
|
|
273
|
+
private coalesceSplitWebmMagic(streamId: AuStreamId, payload: Uint8Array) {
|
|
274
|
+
const pendingPrefix = this.pendingWebmPrefixByStream.get(streamId);
|
|
275
|
+
if (pendingPrefix) {
|
|
276
|
+
const merged = new Uint8Array(pendingPrefix.byteLength + payload.byteLength);
|
|
277
|
+
merged.set(pendingPrefix);
|
|
278
|
+
merged.set(payload, pendingPrefix.byteLength);
|
|
279
|
+
this.pendingWebmPrefixByStream.delete(streamId);
|
|
280
|
+
payload = merged;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (
|
|
284
|
+
payload.byteLength < WEBM_EBML_MAGIC.byteLength
|
|
285
|
+
&& payload.every((byte, index) => byte === WEBM_EBML_MAGIC[index])
|
|
286
|
+
) {
|
|
287
|
+
this.pendingWebmPrefixByStream.set(streamId, payload.slice());
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return payload;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private flushOlderNearend(currentFrameKey: number) {
|
|
295
|
+
for (const frameKey of [...this.pendingNearend.keys()].sort((a, b) => a - b)) {
|
|
296
|
+
if (frameKey >= currentFrameKey) {
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
this.flushBucket(frameKey, true);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private flushExpiredNearend(currentFrameKey: number) {
|
|
304
|
+
this.flushOlderNearend(currentFrameKey - NEAREND_GRACE_BUCKETS);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private flushBucket(frameKey: number, allowPreviousFarend = false) {
|
|
308
|
+
const nearend = this.takeNearend(frameKey);
|
|
309
|
+
if (!nearend) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const previousFarendKey = frameKey - MAX_FAREND_BUCKET_SKEW;
|
|
313
|
+
const hasMatchingFarend = this.pendingFarend.has(frameKey)
|
|
314
|
+
|| (allowPreviousFarend && this.pendingFarend.has(previousFarendKey));
|
|
315
|
+
const farend = hasMatchingFarend
|
|
316
|
+
? this.takeFarendThrough(frameKey)
|
|
317
|
+
: null;
|
|
318
|
+
this.closedNearendBuckets.add(frameKey);
|
|
319
|
+
logAudioUplink("bucket.flush", {
|
|
320
|
+
nearendFrameKey: frameKey,
|
|
321
|
+
pairedFarend: Boolean(farend),
|
|
322
|
+
pendingFarend: [...this.pendingFarend.keys()],
|
|
323
|
+
});
|
|
324
|
+
this.enqueue(farend ? [farend, nearend] : [nearend]);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private takeNearend(frameKey: number): PendingAudioBlock | null {
|
|
328
|
+
const blocks = this.pendingNearend.get(frameKey);
|
|
329
|
+
this.pendingNearend.delete(frameKey);
|
|
330
|
+
if (!blocks?.length) {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const first = blocks[0];
|
|
335
|
+
return {
|
|
336
|
+
...first,
|
|
337
|
+
dataParts: blocks.flatMap((block) => block.dataParts ?? [block.data]),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
private takeFarendThrough(frameKey: number): PendingAudioBlock | null {
|
|
342
|
+
const blocks: PendingAudioBlock[] = [];
|
|
343
|
+
for (const key of [...this.pendingFarend.keys()].sort((a, b) => a - b)) {
|
|
344
|
+
if (key > frameKey) {
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
blocks.push(...this.pendingFarend.get(key)!);
|
|
348
|
+
this.pendingFarend.delete(key);
|
|
349
|
+
}
|
|
350
|
+
if (blocks.length === 0) {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const latest = blocks[blocks.length - 1];
|
|
355
|
+
return {
|
|
356
|
+
...latest,
|
|
357
|
+
dataParts: blocks.map((block) => block.data),
|
|
358
|
+
isFirstChunk: blocks.some((block) => block.isFirstChunk),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private abandonFarendStream() {
|
|
363
|
+
logAudioUplink("farend.drop", {
|
|
364
|
+
reason: "stale_bucket_abandons_webm_stream",
|
|
365
|
+
pendingFarend: [...this.pendingFarend.keys()],
|
|
366
|
+
});
|
|
367
|
+
this.pendingFarend.clear();
|
|
368
|
+
this.pendingWebmPrefixByStream.delete(AU_STREAM_FAREND);
|
|
369
|
+
this.farendStreamState = "awaiting-header";
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private prune(currentFrameKey: number) {
|
|
373
|
+
const oldestAllowed = currentFrameKey - MAX_PENDING_FAREND_BUCKETS;
|
|
374
|
+
if ([...this.pendingFarend.keys()].some((frameKey) => frameKey < oldestAllowed)) {
|
|
375
|
+
// Dropping an arbitrary WebM fragment would corrupt every continuation
|
|
376
|
+
// after it. Abandon this container and wait for the next explicit header.
|
|
377
|
+
this.abandonFarendStream();
|
|
378
|
+
}
|
|
379
|
+
for (const frameKey of this.pendingNearend.keys()) {
|
|
380
|
+
if (frameKey < oldestAllowed) {
|
|
381
|
+
this.flushBucket(frameKey, true);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
for (const frameKey of this.closedNearendBuckets) {
|
|
385
|
+
if (frameKey < oldestAllowed) {
|
|
386
|
+
this.closedNearendBuckets.delete(frameKey);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export const AU_AUDIO_BLOCK_MS = AUDIO_BLOCK_MS;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import type { AgentClientMessage, AgentServerEvent, AgentSocketState } from "./types";
|
|
2
|
+
|
|
3
|
+
const MAX_BUFFERED_AMOUNT = 2 * 1024 * 1024;
|
|
4
|
+
|
|
5
|
+
export interface AgentE2EClientOptions {
|
|
6
|
+
wsUrl: string;
|
|
7
|
+
token: string;
|
|
8
|
+
audioInputEnabled?: boolean;
|
|
9
|
+
WebSocketCtor?: typeof WebSocket;
|
|
10
|
+
onStateChange?: (state: AgentSocketState) => void;
|
|
11
|
+
onEvent?: (event: AgentServerEvent) => void;
|
|
12
|
+
onError?: (error: Error) => void;
|
|
13
|
+
onClose?: (event: CloseEvent) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class AgentE2EClient {
|
|
17
|
+
private ws: WebSocket | null = null;
|
|
18
|
+
private connectPromise: Promise<void> | null = null;
|
|
19
|
+
private rejectConnect: ((error: Error) => void) | null = null;
|
|
20
|
+
private audioInputEnabled: boolean;
|
|
21
|
+
private readonly WebSocketCtor: typeof WebSocket;
|
|
22
|
+
private readonly options: AgentE2EClientOptions;
|
|
23
|
+
|
|
24
|
+
constructor(options: AgentE2EClientOptions) {
|
|
25
|
+
this.options = options;
|
|
26
|
+
this.audioInputEnabled = options.audioInputEnabled ?? true;
|
|
27
|
+
const WebSocketCtor = options.WebSocketCtor || globalThis.WebSocket;
|
|
28
|
+
if (!WebSocketCtor) {
|
|
29
|
+
throw new Error("当前环境不支持 WebSocket");
|
|
30
|
+
}
|
|
31
|
+
this.WebSocketCtor = WebSocketCtor;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get isOpen() {
|
|
35
|
+
return this.ws?.readyState === this.WebSocketCtor.OPEN;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
connect(): Promise<void> {
|
|
39
|
+
if (this.isOpen) {
|
|
40
|
+
return Promise.resolve();
|
|
41
|
+
}
|
|
42
|
+
if (this.connectPromise) {
|
|
43
|
+
return this.connectPromise;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
this.options.onStateChange?.("connecting");
|
|
47
|
+
|
|
48
|
+
let connection: Promise<void>;
|
|
49
|
+
connection = new Promise<void>((resolve, reject) => {
|
|
50
|
+
this.rejectConnect = reject;
|
|
51
|
+
let settled = false;
|
|
52
|
+
const ws = new this.WebSocketCtor(this.buildWsUrl(this.options.wsUrl, this.options.token));
|
|
53
|
+
this.ws = ws;
|
|
54
|
+
|
|
55
|
+
ws.onopen = () => {
|
|
56
|
+
if (this.ws !== ws) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
settled = true;
|
|
60
|
+
if (this.connectPromise === connection) {
|
|
61
|
+
this.connectPromise = null;
|
|
62
|
+
}
|
|
63
|
+
this.rejectConnect = null;
|
|
64
|
+
this.options.onStateChange?.("open");
|
|
65
|
+
resolve();
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
ws.onmessage = (message) => {
|
|
69
|
+
if (this.ws !== ws) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const event = this.parseMessage(message.data);
|
|
73
|
+
if (event) {
|
|
74
|
+
this.options.onEvent?.(event);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
ws.onerror = () => {
|
|
79
|
+
if (this.ws !== ws) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const error = new Error("E2E WebSocket 连接异常");
|
|
83
|
+
if (!settled) {
|
|
84
|
+
settled = true;
|
|
85
|
+
if (this.connectPromise === connection) {
|
|
86
|
+
this.connectPromise = null;
|
|
87
|
+
}
|
|
88
|
+
this.rejectConnect = null;
|
|
89
|
+
reject(error);
|
|
90
|
+
}
|
|
91
|
+
this.options.onStateChange?.("error");
|
|
92
|
+
this.options.onError?.(error);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
ws.onclose = (event) => {
|
|
96
|
+
if (this.ws !== ws) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
this.ws = null;
|
|
100
|
+
if (!settled) {
|
|
101
|
+
settled = true;
|
|
102
|
+
if (this.connectPromise === connection) {
|
|
103
|
+
this.connectPromise = null;
|
|
104
|
+
}
|
|
105
|
+
this.rejectConnect = null;
|
|
106
|
+
reject(new Error(`E2E WebSocket 已关闭: ${event.code} ${event.reason || ""}`.trim()));
|
|
107
|
+
}
|
|
108
|
+
this.options.onStateChange?.("closed");
|
|
109
|
+
this.options.onClose?.(event);
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
this.connectPromise = connection;
|
|
113
|
+
return this.connectPromise;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
disconnect(code = 1000, reason = "client_close") {
|
|
117
|
+
if (!this.ws) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const ws = this.ws;
|
|
122
|
+
this.ws = null;
|
|
123
|
+
this.connectPromise = null;
|
|
124
|
+
const rejectConnect = this.rejectConnect;
|
|
125
|
+
this.rejectConnect = null;
|
|
126
|
+
rejectConnect?.(new Error("E2E WebSocket 连接已取消"));
|
|
127
|
+
|
|
128
|
+
if (ws.readyState === this.WebSocketCtor.OPEN || ws.readyState === this.WebSocketCtor.CONNECTING) {
|
|
129
|
+
ws.close(code, reason);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
send(message: AgentClientMessage) {
|
|
134
|
+
this.assertOpen();
|
|
135
|
+
this.ws?.send(JSON.stringify(message));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
trySend(message: AgentClientMessage) {
|
|
139
|
+
this.assertOpen();
|
|
140
|
+
if (this.ws && this.ws.bufferedAmount > MAX_BUFFERED_AMOUNT) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
this.ws?.send(JSON.stringify(message));
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
sendBinary(frame: ArrayBuffer | ArrayBufferView | Blob) {
|
|
148
|
+
this.assertOpen();
|
|
149
|
+
if (this.ws && this.ws.bufferedAmount > MAX_BUFFERED_AMOUNT) {
|
|
150
|
+
const error = new Error("E2E WebSocket 发送缓冲区超限");
|
|
151
|
+
error.name = "WebSocketBackpressureError";
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
this.ws?.send(frame as any);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
ping() {
|
|
158
|
+
this.send({ type: "ping" });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
setAudioInputEnabled(enabled: boolean) {
|
|
162
|
+
this.audioInputEnabled = enabled;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private assertOpen() {
|
|
166
|
+
if (!this.isOpen) {
|
|
167
|
+
throw new Error("E2E WebSocket 未连接");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private parseMessage(data: unknown): AgentServerEvent | null {
|
|
172
|
+
if (typeof data !== "string") {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const parsed = JSON.parse(data);
|
|
178
|
+
if (parsed && typeof parsed.type === "string") {
|
|
179
|
+
return parsed as AgentServerEvent;
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
} catch (error) {
|
|
183
|
+
this.options.onError?.(new Error("E2E WebSocket 消息不是合法 JSON"));
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private buildWsUrl(wsUrl: string, token: string) {
|
|
189
|
+
try {
|
|
190
|
+
const url = new URL(wsUrl);
|
|
191
|
+
if (token && !url.searchParams.has("token") && !url.searchParams.has("e2e_token")) {
|
|
192
|
+
url.searchParams.set("token", token);
|
|
193
|
+
}
|
|
194
|
+
this.updateAudioInputQuery(url.searchParams);
|
|
195
|
+
return url.toString();
|
|
196
|
+
} catch {
|
|
197
|
+
const [baseUrl, query = ""] = wsUrl.split("?", 2);
|
|
198
|
+
const searchParams = new URLSearchParams(query);
|
|
199
|
+
if (token && !searchParams.has("token") && !searchParams.has("e2e_token")) {
|
|
200
|
+
searchParams.set("token", token);
|
|
201
|
+
}
|
|
202
|
+
this.updateAudioInputQuery(searchParams);
|
|
203
|
+
const nextQuery = searchParams.toString();
|
|
204
|
+
return nextQuery ? `${baseUrl}?${nextQuery}` : baseUrl;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private updateAudioInputQuery(searchParams: URLSearchParams) {
|
|
209
|
+
if (!this.audioInputEnabled) {
|
|
210
|
+
searchParams.set("audio_input_off", "1");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
searchParams.delete("audio_input_off");
|
|
214
|
+
}
|
|
215
|
+
}
|