@xmov/avatar 2.0.1 → 2.1.1
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 +3 -1
- package/dist/agent/avatar.cjs +1 -2
- package/dist/agent/avatar.modern.js +1 -2
- package/dist/agent/avatar.module.js +1 -2
- package/dist/agent/avatar.umd.js +1 -2
- package/dist/agent/index.cjs +1 -4
- package/dist/agent/index.d.ts +12 -5
- package/dist/agent/index.umd.js +3 -5
- package/dist/agent/types.d.ts +5 -43
- package/dist/baseRender/AvatarRenderer.d.ts +0 -1
- package/dist/control/RenderScheduler.d.ts +17 -1
- package/dist/control/ttsa.d.ts +8 -1
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.modern.js +1 -1
- package/dist/index.modern.js.map +1 -1
- package/dist/index.module.js +1 -1
- package/dist/index.module.js.map +1 -1
- package/dist/index.umd.js +1 -1
- package/dist/index.umd.js.map +1 -1
- package/dist/modules/ResourceManager.d.ts +2 -1
- package/dist/utils/request.d.ts +1 -0
- package/package.json +29 -7
- package/src/baseRender/AudioRenderer.ts +16 -5
- package/src/baseRender/AvatarRenderer.ts +11 -20
- package/src/baseRender/MSEAudioPlayer.ts +18 -7
- package/src/control/RenderScheduler.ts +74 -8
- package/src/control/ttsa.ts +47 -11
- package/src/index.ts +66 -45
- package/src/modules/ResourceManager.ts +19 -6
- package/src/modules/decoder.ts +34 -1
- package/src/utils/capability-checker.ts +95 -21
- package/src/utils/request.ts +14 -2
- package/src/view/DebugOverlay.ts +9 -4
- package/dist/agent/__tests__/agent.test.d.ts +0 -1
- package/dist/agent/audio-debug.d.ts +0 -3
- package/dist/agent/audio-uplink.d.ts +0 -62
- package/dist/agent/avatar.cjs.map +0 -1
- package/dist/agent/avatar.modern.js.map +0 -1
- package/dist/agent/avatar.module.js.map +0 -1
- package/dist/agent/avatar.umd.js.map +0 -1
- package/dist/agent/e2e-client.d.ts +0 -32
- package/dist/agent/fixed-audio-track.d.ts +0 -48
- package/dist/agent/microphone.d.ts +0 -49
- package/src/agent/__tests__/agent.test.ts +0 -3787
- package/src/agent/audio-debug.ts +0 -36
- package/src/agent/audio-uplink.ts +0 -392
- package/src/agent/e2e-client.ts +0 -215
- package/src/agent/fixed-audio-track.ts +0 -547
- package/src/agent/index.ts +0 -1393
- package/src/agent/microphone.ts +0 -534
- package/src/agent/types.ts +0 -197
package/src/agent/microphone.ts
DELETED
|
@@ -1,534 +0,0 @@
|
|
|
1
|
-
import type { AgentAudioMetadata, AgentAudioOptions } from "./types";
|
|
2
|
-
import { FixedAudioTrack } from "./fixed-audio-track";
|
|
3
|
-
import { DEFAULT_AGENT_AUDIO } from "./types";
|
|
4
|
-
import { logAudioChunkContent } from "./audio-debug";
|
|
5
|
-
import {
|
|
6
|
-
beginWebAudioCaptureSession,
|
|
7
|
-
endWebAudioCaptureSession,
|
|
8
|
-
} from "../utils/audio-session";
|
|
9
|
-
import { MediaRecorderTimestamp } from "../utils/media-recorder-timestamp";
|
|
10
|
-
import { PcmWebMEncoder } from "../encoding/pcm-webm-encoder";
|
|
11
|
-
|
|
12
|
-
const WEBM_OPUS_MIME_TYPE = "audio/webm;codecs=opus";
|
|
13
|
-
const TARGET_SAMPLE_RATE = 16_000;
|
|
14
|
-
const TARGET_CHANNELS = 1;
|
|
15
|
-
const PCM_BLOCK_MS = 100;
|
|
16
|
-
const PCM_CAPTURE_PROCESSOR = "agent-pcm-capture-processor";
|
|
17
|
-
const PCM_CAPTURE_WORKLET = `
|
|
18
|
-
class AgentPcmCaptureProcessor extends AudioWorkletProcessor {
|
|
19
|
-
constructor() {
|
|
20
|
-
super();
|
|
21
|
-
this.buffer = new Float32Array(1600);
|
|
22
|
-
this.bufferIndex = 0;
|
|
23
|
-
this.sequence = 0;
|
|
24
|
-
this.port.onmessage = (event) => {
|
|
25
|
-
if (event.data?.type === 'flush') {
|
|
26
|
-
this.emitBlock(true);
|
|
27
|
-
this.port.postMessage({ type: 'flushed' });
|
|
28
|
-
}
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
emitBlock(padPartial = false) {
|
|
33
|
-
if (this.bufferIndex === 0 || (!padPartial && this.bufferIndex < 1600)) return;
|
|
34
|
-
const pcm = new Int16Array(1600);
|
|
35
|
-
for (let index = 0; index < this.bufferIndex; index += 1) {
|
|
36
|
-
const sample = Math.max(-1, Math.min(1, this.buffer[index]));
|
|
37
|
-
pcm[index] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
|
|
38
|
-
}
|
|
39
|
-
this.port.postMessage(
|
|
40
|
-
{ type: 'frame', pcm: pcm.buffer, sequence: this.sequence++ },
|
|
41
|
-
[pcm.buffer],
|
|
42
|
-
);
|
|
43
|
-
this.buffer.fill(0);
|
|
44
|
-
this.bufferIndex = 0;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
process(inputs, outputs) {
|
|
48
|
-
for (const output of outputs) {
|
|
49
|
-
for (const channel of output) channel.fill(0);
|
|
50
|
-
}
|
|
51
|
-
const channels = inputs[0];
|
|
52
|
-
if (!channels?.length || !channels[0]?.length) return true;
|
|
53
|
-
for (let frame = 0; frame < channels[0].length; frame += 1) {
|
|
54
|
-
let sample = 0;
|
|
55
|
-
for (let channel = 0; channel < channels.length; channel += 1) {
|
|
56
|
-
sample += channels[channel][frame] || 0;
|
|
57
|
-
}
|
|
58
|
-
this.buffer[this.bufferIndex++] = sample / channels.length;
|
|
59
|
-
if (this.bufferIndex === 1600) this.emitBlock();
|
|
60
|
-
}
|
|
61
|
-
return true;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
registerProcessor('${PCM_CAPTURE_PROCESSOR}', AgentPcmCaptureProcessor);
|
|
65
|
-
`;
|
|
66
|
-
type AgentCodedError = Error & {
|
|
67
|
-
agentCode?: string;
|
|
68
|
-
retryable?: boolean;
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
export interface MicrophoneControllerOptions {
|
|
72
|
-
audio?: AgentAudioOptions;
|
|
73
|
-
debugAudioChunks?: boolean;
|
|
74
|
-
/** Agent-owned input stream. When omitted, the controller requests the microphone. */
|
|
75
|
-
inputStream?: MediaStream;
|
|
76
|
-
/** Use deterministic 100ms PCM buckets with WebCodecs Opus encoding. */
|
|
77
|
-
usePcmWebCodecs?: boolean;
|
|
78
|
-
onMetadata?: (metadata: AgentAudioMetadata) => void;
|
|
79
|
-
/** WebM/Opus Blob callback; timestamp is the media-block start time. */
|
|
80
|
-
onFrame: (frame: Blob, timestamp?: number) => void;
|
|
81
|
-
onError?: (error: Error) => void;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export class MicrophoneController {
|
|
85
|
-
private stream: MediaStream | null = null;
|
|
86
|
-
private pendingInputStream: MediaStream | null;
|
|
87
|
-
private recordingStream: MediaStream | null = null;
|
|
88
|
-
private audioTransformer: FixedAudioTrack | null = null;
|
|
89
|
-
private mediaRecorder: MediaRecorder | null = null;
|
|
90
|
-
private pcmAudioContext: AudioContext | null = null;
|
|
91
|
-
private pcmSource: MediaStreamAudioSourceNode | null = null;
|
|
92
|
-
private pcmWorklet: AudioWorkletNode | null = null;
|
|
93
|
-
private pcmEncoder: PcmWebMEncoder | null = null;
|
|
94
|
-
private pcmEncodeQueue: Promise<void> = Promise.resolve();
|
|
95
|
-
private pcmStartedAt = 0;
|
|
96
|
-
private pcmGeneration = 0;
|
|
97
|
-
private resolvePcmFlush: (() => void) | null = null;
|
|
98
|
-
private startGeneration = 0;
|
|
99
|
-
private starting = false;
|
|
100
|
-
private hasAudioSessionCapture = false;
|
|
101
|
-
private metadata: AgentAudioMetadata;
|
|
102
|
-
private readonly options: MicrophoneControllerOptions;
|
|
103
|
-
private readonly usesProvidedInput: boolean;
|
|
104
|
-
|
|
105
|
-
constructor(options: MicrophoneControllerOptions) {
|
|
106
|
-
this.options = options;
|
|
107
|
-
this.pendingInputStream = options.inputStream ?? null;
|
|
108
|
-
this.usesProvidedInput = Boolean(options.inputStream);
|
|
109
|
-
this.metadata = {
|
|
110
|
-
...DEFAULT_AGENT_AUDIO,
|
|
111
|
-
chunkMs: options.audio?.chunkMs || DEFAULT_AGENT_AUDIO.chunkMs,
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
get isRecording() {
|
|
116
|
-
return this.starting || Boolean(this.stream) || Boolean(this.pendingInputStream);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
getAudioMetadata() {
|
|
120
|
-
return this.metadata;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
async start() {
|
|
124
|
-
if (this.stream || this.starting) {
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const startGeneration = ++this.startGeneration;
|
|
129
|
-
this.starting = true;
|
|
130
|
-
try {
|
|
131
|
-
const inputStream = this.pendingInputStream;
|
|
132
|
-
this.stream = inputStream ?? null;
|
|
133
|
-
this.pendingInputStream = null;
|
|
134
|
-
const mediaDevices = navigator.mediaDevices;
|
|
135
|
-
if (!this.stream && !mediaDevices?.getUserMedia) {
|
|
136
|
-
throw new Error("当前浏览器不支持麦克风采集");
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const MediaRecorderCtor = globalThis.MediaRecorder;
|
|
140
|
-
if (this.options.usePcmWebCodecs) {
|
|
141
|
-
if (!await PcmWebMEncoder.isSupported()) {
|
|
142
|
-
throw this.webCodecsOpusUnsupported();
|
|
143
|
-
}
|
|
144
|
-
} else if (!MediaRecorderCtor?.isTypeSupported?.(WEBM_OPUS_MIME_TYPE)) {
|
|
145
|
-
throw this.webMOpusUnsupported();
|
|
146
|
-
}
|
|
147
|
-
this.assertStartActive(startGeneration);
|
|
148
|
-
|
|
149
|
-
// A playback-only session can prevent iOS WebKit from starting a
|
|
150
|
-
// microphone track. Switch categories before requesting capture.
|
|
151
|
-
if (!this.stream) {
|
|
152
|
-
if (this.usesProvidedInput) {
|
|
153
|
-
throw inputStream ? this.cancelled() : new Error("ASR 输入流已释放");
|
|
154
|
-
}
|
|
155
|
-
this.hasAudioSessionCapture = beginWebAudioCaptureSession();
|
|
156
|
-
this.stream = await mediaDevices.getUserMedia({
|
|
157
|
-
audio: {
|
|
158
|
-
sampleRate: { ideal: TARGET_SAMPLE_RATE },
|
|
159
|
-
channelCount: { ideal: TARGET_CHANNELS },
|
|
160
|
-
echoCancellation: true,
|
|
161
|
-
noiseSuppression: true,
|
|
162
|
-
autoGainControl: true,
|
|
163
|
-
},
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
this.assertStartActive(startGeneration);
|
|
167
|
-
const inputTrack = this.stream.getAudioTracks()[0];
|
|
168
|
-
if (!inputTrack) {
|
|
169
|
-
throw new Error("麦克风未返回音频轨道");
|
|
170
|
-
}
|
|
171
|
-
if (this.options.usePcmWebCodecs) {
|
|
172
|
-
await this.startPcmWebMRecorder(this.stream);
|
|
173
|
-
this.assertStartActive(startGeneration);
|
|
174
|
-
this.metadata = {
|
|
175
|
-
...this.metadata,
|
|
176
|
-
sampleRate: TARGET_SAMPLE_RATE,
|
|
177
|
-
channels: TARGET_CHANNELS,
|
|
178
|
-
chunkMs: PCM_BLOCK_MS,
|
|
179
|
-
};
|
|
180
|
-
this.options.onMetadata?.(this.metadata);
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
|
-
const audioTransformer = new FixedAudioTrack({
|
|
184
|
-
onError: (error) => {
|
|
185
|
-
const normalized = error instanceof Error ? error : new Error("16 kHz mono 音频处理失败");
|
|
186
|
-
normalized.name = "MediaRecorderError";
|
|
187
|
-
this.options.onError?.(normalized);
|
|
188
|
-
},
|
|
189
|
-
});
|
|
190
|
-
this.audioTransformer = audioTransformer;
|
|
191
|
-
this.recordingStream = await audioTransformer.start(inputTrack);
|
|
192
|
-
this.assertStartActive(startGeneration);
|
|
193
|
-
const outputTrack = this.recordingStream.getAudioTracks()[0];
|
|
194
|
-
const outputSettings = outputTrack?.getSettings() as
|
|
195
|
-
| (MediaTrackSettings & { sampleRate?: number; channelCount?: number })
|
|
196
|
-
| undefined;
|
|
197
|
-
this.metadata = {
|
|
198
|
-
...this.metadata,
|
|
199
|
-
sampleRate: outputSettings?.sampleRate || TARGET_SAMPLE_RATE,
|
|
200
|
-
channels: outputSettings?.channelCount || TARGET_CHANNELS,
|
|
201
|
-
};
|
|
202
|
-
this.startWebMRecorder(MediaRecorderCtor);
|
|
203
|
-
this.options.onMetadata?.(this.metadata);
|
|
204
|
-
} catch (error) {
|
|
205
|
-
const startCancelled = startGeneration !== this.startGeneration;
|
|
206
|
-
await this.stop();
|
|
207
|
-
const normalized: AgentCodedError = startCancelled
|
|
208
|
-
? this.cancelled()
|
|
209
|
-
: error instanceof Error
|
|
210
|
-
? error
|
|
211
|
-
: new Error("麦克风启动失败");
|
|
212
|
-
if (normalized.agentCode !== "AUDIO_FIXED_TRACK_CANCELLED") {
|
|
213
|
-
this.options.onError?.(normalized);
|
|
214
|
-
}
|
|
215
|
-
throw normalized;
|
|
216
|
-
} finally {
|
|
217
|
-
if (this.startGeneration === startGeneration) {
|
|
218
|
-
this.starting = false;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
async stop() {
|
|
224
|
-
this.startGeneration += 1;
|
|
225
|
-
this.starting = false;
|
|
226
|
-
try {
|
|
227
|
-
const audioTransformer = this.audioTransformer;
|
|
228
|
-
try {
|
|
229
|
-
// Drain the processor first, but keep the generator track live so the
|
|
230
|
-
// recorder can capture the flushed tail before it is explicitly stopped.
|
|
231
|
-
await audioTransformer?.drain();
|
|
232
|
-
} finally {
|
|
233
|
-
await this.stopPcmWebMRecorder();
|
|
234
|
-
await this.stopMediaRecorder();
|
|
235
|
-
await audioTransformer?.stop();
|
|
236
|
-
this.audioTransformer = null;
|
|
237
|
-
}
|
|
238
|
-
this.recordingStream = null;
|
|
239
|
-
|
|
240
|
-
if (this.stream && !audioTransformer) {
|
|
241
|
-
this.stream.getTracks().forEach((track) => track.stop());
|
|
242
|
-
}
|
|
243
|
-
this.stream = null;
|
|
244
|
-
this.pendingInputStream?.getTracks().forEach((track) => track.stop());
|
|
245
|
-
this.pendingInputStream = null;
|
|
246
|
-
} finally {
|
|
247
|
-
if (this.hasAudioSessionCapture) {
|
|
248
|
-
this.hasAudioSessionCapture = false;
|
|
249
|
-
endWebAudioCaptureSession();
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
private startWebMRecorder(MediaRecorderCtor: typeof MediaRecorder) {
|
|
255
|
-
if (!this.recordingStream) {
|
|
256
|
-
throw new Error("16 kHz mono 音频流未就绪");
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
let recorder: MediaRecorder;
|
|
260
|
-
try {
|
|
261
|
-
recorder = new MediaRecorderCtor(this.recordingStream, {
|
|
262
|
-
mimeType: WEBM_OPUS_MIME_TYPE,
|
|
263
|
-
audioBitsPerSecond: 24000,
|
|
264
|
-
});
|
|
265
|
-
} catch (error) {
|
|
266
|
-
if (error instanceof Error && error.name === "NotSupportedError") {
|
|
267
|
-
throw this.webMOpusUnsupported(error);
|
|
268
|
-
}
|
|
269
|
-
throw error;
|
|
270
|
-
}
|
|
271
|
-
const recorderTimestamp = new MediaRecorderTimestamp(this.metadata.chunkMs);
|
|
272
|
-
recorder.ondataavailable = (event) => {
|
|
273
|
-
if (event.data.size > 0) {
|
|
274
|
-
const timestamp = recorderTimestamp.resolve(event.timecode);
|
|
275
|
-
(globalThis as typeof globalThis & { avatarSDKLogger?: { log?: (...args: unknown[]) => void } })
|
|
276
|
-
.avatarSDKLogger?.log?.("[Agent][Microphone]", "audio chunk", {
|
|
277
|
-
size: event.data.size,
|
|
278
|
-
type: event.data.type,
|
|
279
|
-
chunkMs: this.metadata.chunkMs,
|
|
280
|
-
sampleRate: this.metadata.sampleRate,
|
|
281
|
-
channels: this.metadata.channels,
|
|
282
|
-
recorderState: recorder.state,
|
|
283
|
-
timecode: event.timecode,
|
|
284
|
-
});
|
|
285
|
-
if (this.options.debugAudioChunks) {
|
|
286
|
-
void logAudioChunkContent("[Agent][Microphone] nearend WebM/Opus chunk", event.data, {
|
|
287
|
-
chunkMs: this.metadata.chunkMs,
|
|
288
|
-
sampleRate: this.metadata.sampleRate,
|
|
289
|
-
channels: this.metadata.channels,
|
|
290
|
-
recorderState: recorder.state,
|
|
291
|
-
timecode: event.timecode,
|
|
292
|
-
});
|
|
293
|
-
}
|
|
294
|
-
this.options.onFrame(event.data, timestamp);
|
|
295
|
-
}
|
|
296
|
-
};
|
|
297
|
-
recorder.onerror = (event) => {
|
|
298
|
-
const error = (event as ErrorEvent).error;
|
|
299
|
-
const recorderError = new Error(error instanceof Error ? error.message : "WebM/Opus 麦克风录制失败");
|
|
300
|
-
recorderError.name = "MediaRecorderError";
|
|
301
|
-
this.options.onError?.(recorderError);
|
|
302
|
-
};
|
|
303
|
-
this.mediaRecorder = recorder;
|
|
304
|
-
try {
|
|
305
|
-
recorder.start(this.metadata.chunkMs);
|
|
306
|
-
} catch (error) {
|
|
307
|
-
if (error instanceof Error && error.name === "NotSupportedError") {
|
|
308
|
-
throw this.webMOpusUnsupported(error);
|
|
309
|
-
}
|
|
310
|
-
throw error;
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
private assertStartActive(startGeneration: number) {
|
|
315
|
-
if (startGeneration !== this.startGeneration) {
|
|
316
|
-
throw this.cancelled();
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
private cancelled() {
|
|
321
|
-
const error = new Error("麦克风启动已取消") as AgentCodedError;
|
|
322
|
-
error.name = "AbortError";
|
|
323
|
-
error.agentCode = "AUDIO_FIXED_TRACK_CANCELLED";
|
|
324
|
-
error.retryable = true;
|
|
325
|
-
return error;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
private webMOpusUnsupported(cause?: unknown) {
|
|
329
|
-
const error = new Error("当前浏览器不支持 audio/webm;codecs=opus") as AgentCodedError & {
|
|
330
|
-
cause?: unknown;
|
|
331
|
-
};
|
|
332
|
-
error.name = "NotSupportedError";
|
|
333
|
-
error.agentCode = "AUDIO_WEBM_OPUS_UNSUPPORTED";
|
|
334
|
-
error.retryable = false;
|
|
335
|
-
error.cause = cause;
|
|
336
|
-
return error;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
private webCodecsOpusUnsupported(cause?: unknown) {
|
|
340
|
-
const error = new Error("当前浏览器不支持 WebCodecs AudioEncoder Opus") as AgentCodedError & {
|
|
341
|
-
cause?: unknown;
|
|
342
|
-
};
|
|
343
|
-
error.name = "NotSupportedError";
|
|
344
|
-
error.agentCode = "AUDIO_WEBCODECS_OPUS_UNSUPPORTED";
|
|
345
|
-
error.retryable = false;
|
|
346
|
-
error.cause = cause;
|
|
347
|
-
return error;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
private async startPcmWebMRecorder(stream: MediaStream) {
|
|
351
|
-
const globals = globalThis as typeof globalThis & {
|
|
352
|
-
webkitAudioContext?: typeof AudioContext;
|
|
353
|
-
};
|
|
354
|
-
const AudioContextCtor = globalThis.AudioContext || globals.webkitAudioContext;
|
|
355
|
-
if (!AudioContextCtor || typeof globalThis.AudioWorkletNode === "undefined") {
|
|
356
|
-
throw this.webCodecsOpusUnsupported(new Error("AudioWorklet 不可用"));
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
const encoder = new PcmWebMEncoder();
|
|
360
|
-
await encoder.init();
|
|
361
|
-
const context = new AudioContextCtor({ sampleRate: TARGET_SAMPLE_RATE });
|
|
362
|
-
if (context.sampleRate !== TARGET_SAMPLE_RATE) {
|
|
363
|
-
await encoder.destroy();
|
|
364
|
-
await context.close().catch(() => undefined);
|
|
365
|
-
throw this.webCodecsOpusUnsupported(
|
|
366
|
-
new Error(`浏览器无法创建 ${TARGET_SAMPLE_RATE} Hz AudioContext`),
|
|
367
|
-
);
|
|
368
|
-
}
|
|
369
|
-
this.pcmAudioContext = context;
|
|
370
|
-
this.pcmEncoder = encoder;
|
|
371
|
-
|
|
372
|
-
const workletUrl = URL.createObjectURL(
|
|
373
|
-
new Blob([PCM_CAPTURE_WORKLET], { type: "application/javascript" }),
|
|
374
|
-
);
|
|
375
|
-
try {
|
|
376
|
-
await context.audioWorklet.addModule(workletUrl);
|
|
377
|
-
} finally {
|
|
378
|
-
URL.revokeObjectURL(workletUrl);
|
|
379
|
-
}
|
|
380
|
-
if (context.state === "suspended") {
|
|
381
|
-
await context.resume();
|
|
382
|
-
}
|
|
383
|
-
if (context.state !== "running") {
|
|
384
|
-
throw this.audioContextSuspended();
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
const source = context.createMediaStreamSource(stream);
|
|
388
|
-
const worklet = new AudioWorkletNode(context, PCM_CAPTURE_PROCESSOR, {
|
|
389
|
-
numberOfInputs: 1,
|
|
390
|
-
numberOfOutputs: 1,
|
|
391
|
-
outputChannelCount: [1],
|
|
392
|
-
});
|
|
393
|
-
const generation = ++this.pcmGeneration;
|
|
394
|
-
this.pcmSource = source;
|
|
395
|
-
this.pcmWorklet = worklet;
|
|
396
|
-
this.pcmEncodeQueue = Promise.resolve();
|
|
397
|
-
this.pcmStartedAt = this.monotonicEpochNow();
|
|
398
|
-
|
|
399
|
-
worklet.port.onmessage = (event) => {
|
|
400
|
-
if (event.data?.type === "flushed") {
|
|
401
|
-
this.resolvePcmFlush?.();
|
|
402
|
-
this.resolvePcmFlush = null;
|
|
403
|
-
return;
|
|
404
|
-
}
|
|
405
|
-
if (event.data?.type !== "frame" || !(event.data.pcm instanceof ArrayBuffer)) {
|
|
406
|
-
return;
|
|
407
|
-
}
|
|
408
|
-
const timestamp = this.pcmStartedAt + Number(event.data.sequence || 0) * PCM_BLOCK_MS;
|
|
409
|
-
const pcm = new Int16Array(event.data.pcm);
|
|
410
|
-
this.pcmEncodeQueue = this.pcmEncodeQueue
|
|
411
|
-
.then(async () => {
|
|
412
|
-
const webm = await encoder.encode(pcm);
|
|
413
|
-
if (generation !== this.pcmGeneration || webm.byteLength === 0) {
|
|
414
|
-
return;
|
|
415
|
-
}
|
|
416
|
-
const frame = new Blob([webm], { type: WEBM_OPUS_MIME_TYPE });
|
|
417
|
-
if (this.options.debugAudioChunks) {
|
|
418
|
-
void logAudioChunkContent("[Agent][Microphone] nearend PCM/WebCodecs chunk", frame, {
|
|
419
|
-
chunkMs: PCM_BLOCK_MS,
|
|
420
|
-
sampleRate: TARGET_SAMPLE_RATE,
|
|
421
|
-
channels: TARGET_CHANNELS,
|
|
422
|
-
sequence: event.data.sequence,
|
|
423
|
-
});
|
|
424
|
-
}
|
|
425
|
-
this.options.onFrame(frame, timestamp);
|
|
426
|
-
})
|
|
427
|
-
.catch((cause) => {
|
|
428
|
-
const error = cause instanceof Error ? cause : new Error("PCM/Opus 麦克风编码失败");
|
|
429
|
-
error.name = "AudioEncoderError";
|
|
430
|
-
this.options.onError?.(error);
|
|
431
|
-
});
|
|
432
|
-
};
|
|
433
|
-
source.connect(worklet);
|
|
434
|
-
worklet.connect(context.destination);
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
private async stopPcmWebMRecorder() {
|
|
438
|
-
const worklet = this.pcmWorklet;
|
|
439
|
-
const source = this.pcmSource;
|
|
440
|
-
const context = this.pcmAudioContext;
|
|
441
|
-
const encoder = this.pcmEncoder;
|
|
442
|
-
if (!worklet && !context && !encoder) {
|
|
443
|
-
return;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
source?.disconnect();
|
|
447
|
-
let flushCompleted = true;
|
|
448
|
-
if (worklet) {
|
|
449
|
-
flushCompleted = await Promise.race([
|
|
450
|
-
new Promise<boolean>((resolve) => {
|
|
451
|
-
this.resolvePcmFlush = () => resolve(true);
|
|
452
|
-
worklet.port.postMessage({ type: "flush" });
|
|
453
|
-
}),
|
|
454
|
-
new Promise<boolean>((resolve) => globalThis.setTimeout(() => resolve(false), 100)),
|
|
455
|
-
]);
|
|
456
|
-
if (!flushCompleted) {
|
|
457
|
-
this.resolvePcmFlush = null;
|
|
458
|
-
worklet.port.onmessage = null;
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
await this.pcmEncodeQueue;
|
|
462
|
-
this.pcmGeneration += 1;
|
|
463
|
-
this.resolvePcmFlush = null;
|
|
464
|
-
if (worklet) {
|
|
465
|
-
worklet.port.onmessage = null;
|
|
466
|
-
worklet.disconnect();
|
|
467
|
-
worklet.port.close();
|
|
468
|
-
}
|
|
469
|
-
if (context && context.state !== "closed") {
|
|
470
|
-
await context.close().catch(() => undefined);
|
|
471
|
-
}
|
|
472
|
-
await encoder?.destroy();
|
|
473
|
-
this.pcmWorklet = null;
|
|
474
|
-
this.pcmSource = null;
|
|
475
|
-
this.pcmAudioContext = null;
|
|
476
|
-
this.pcmEncoder = null;
|
|
477
|
-
this.pcmEncodeQueue = Promise.resolve();
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
private monotonicEpochNow() {
|
|
481
|
-
const clock = globalThis.performance;
|
|
482
|
-
if (clock && Number.isFinite(clock.timeOrigin)) {
|
|
483
|
-
return Math.round(clock.timeOrigin + clock.now());
|
|
484
|
-
}
|
|
485
|
-
return Date.now();
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
private audioContextSuspended() {
|
|
489
|
-
const error = new Error("AudioContext 未运行,请在用户操作后重试") as AgentCodedError;
|
|
490
|
-
error.name = "NotAllowedError";
|
|
491
|
-
error.agentCode = "AUDIO_CONTEXT_SUSPENDED";
|
|
492
|
-
error.retryable = true;
|
|
493
|
-
return error;
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
private async stopMediaRecorder() {
|
|
497
|
-
const recorder = this.mediaRecorder;
|
|
498
|
-
this.mediaRecorder = null;
|
|
499
|
-
if (!recorder) {
|
|
500
|
-
return;
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
const shouldStop = recorder.state !== "inactive";
|
|
504
|
-
await new Promise<void>((resolve) => {
|
|
505
|
-
let settled = false;
|
|
506
|
-
const finish = () => {
|
|
507
|
-
if (settled) {
|
|
508
|
-
return;
|
|
509
|
-
}
|
|
510
|
-
settled = true;
|
|
511
|
-
globalThis.clearTimeout(timeoutId);
|
|
512
|
-
resolve();
|
|
513
|
-
};
|
|
514
|
-
const onError = recorder.onerror;
|
|
515
|
-
const timeoutId = globalThis.setTimeout(finish, 1000);
|
|
516
|
-
recorder.onstop = finish;
|
|
517
|
-
recorder.onerror = (event) => {
|
|
518
|
-
onError?.call(recorder, event);
|
|
519
|
-
finish();
|
|
520
|
-
};
|
|
521
|
-
if (shouldStop) {
|
|
522
|
-
try {
|
|
523
|
-
recorder.stop();
|
|
524
|
-
} catch {
|
|
525
|
-
finish();
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
});
|
|
529
|
-
recorder.ondataavailable = null;
|
|
530
|
-
recorder.onerror = null;
|
|
531
|
-
recorder.onstop = null;
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
}
|