@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.
Files changed (53) hide show
  1. package/README.md +3 -1
  2. package/dist/agent/avatar.cjs +1 -2
  3. package/dist/agent/avatar.modern.js +1 -2
  4. package/dist/agent/avatar.module.js +1 -2
  5. package/dist/agent/avatar.umd.js +1 -2
  6. package/dist/agent/index.cjs +1 -4
  7. package/dist/agent/index.d.ts +12 -5
  8. package/dist/agent/index.umd.js +3 -5
  9. package/dist/agent/types.d.ts +5 -43
  10. package/dist/baseRender/AvatarRenderer.d.ts +0 -1
  11. package/dist/control/RenderScheduler.d.ts +17 -1
  12. package/dist/control/ttsa.d.ts +8 -1
  13. package/dist/index.cjs +1 -1
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.ts +5 -1
  16. package/dist/index.modern.js +1 -1
  17. package/dist/index.modern.js.map +1 -1
  18. package/dist/index.module.js +1 -1
  19. package/dist/index.module.js.map +1 -1
  20. package/dist/index.umd.js +1 -1
  21. package/dist/index.umd.js.map +1 -1
  22. package/dist/modules/ResourceManager.d.ts +2 -1
  23. package/dist/utils/request.d.ts +1 -0
  24. package/package.json +29 -7
  25. package/src/baseRender/AudioRenderer.ts +16 -5
  26. package/src/baseRender/AvatarRenderer.ts +11 -20
  27. package/src/baseRender/MSEAudioPlayer.ts +18 -7
  28. package/src/control/RenderScheduler.ts +74 -8
  29. package/src/control/ttsa.ts +47 -11
  30. package/src/index.ts +66 -45
  31. package/src/modules/ResourceManager.ts +19 -6
  32. package/src/modules/decoder.ts +34 -1
  33. package/src/utils/capability-checker.ts +95 -21
  34. package/src/utils/request.ts +14 -2
  35. package/src/view/DebugOverlay.ts +9 -4
  36. package/dist/agent/__tests__/agent.test.d.ts +0 -1
  37. package/dist/agent/audio-debug.d.ts +0 -3
  38. package/dist/agent/audio-uplink.d.ts +0 -62
  39. package/dist/agent/avatar.cjs.map +0 -1
  40. package/dist/agent/avatar.modern.js.map +0 -1
  41. package/dist/agent/avatar.module.js.map +0 -1
  42. package/dist/agent/avatar.umd.js.map +0 -1
  43. package/dist/agent/e2e-client.d.ts +0 -32
  44. package/dist/agent/fixed-audio-track.d.ts +0 -48
  45. package/dist/agent/microphone.d.ts +0 -49
  46. package/src/agent/__tests__/agent.test.ts +0 -3787
  47. package/src/agent/audio-debug.ts +0 -36
  48. package/src/agent/audio-uplink.ts +0 -392
  49. package/src/agent/e2e-client.ts +0 -215
  50. package/src/agent/fixed-audio-track.ts +0 -547
  51. package/src/agent/index.ts +0 -1393
  52. package/src/agent/microphone.ts +0 -534
  53. package/src/agent/types.ts +0 -197
@@ -1,547 +0,0 @@
1
- const TARGET_SAMPLE_RATE = 16_000;
2
- const TARGET_CHANNELS = 1;
3
- const OUTPUT_FRAME_SIZE = 320;
4
- const MIN_INPUT_SAMPLE_RATE = 8_000;
5
- const MAX_INPUT_SAMPLE_RATE = 192_000;
6
- const MAX_INPUT_FRAMES_PER_READ = 96_000;
7
- const MAX_RESAMPLE_BUFFER_LENGTH = MAX_INPUT_FRAMES_PER_READ * 2;
8
-
9
- interface AudioDataLike {
10
- sampleRate: number;
11
- numberOfChannels: number;
12
- numberOfFrames: number;
13
- timestamp: number;
14
- copyTo(destination: Float32Array, options?: { format?: string; planeIndex?: number }): void;
15
- close(): void;
16
- }
17
-
18
- interface AudioDataConstructorLike {
19
- new (init: {
20
- format: "f32-planar";
21
- sampleRate: number;
22
- numberOfFrames: number;
23
- numberOfChannels: number;
24
- timestamp: number;
25
- data: Float32Array;
26
- }): AudioDataLike;
27
- }
28
-
29
- interface AudioTrackProcessorLike {
30
- readable: ReadableStream<AudioDataLike>;
31
- }
32
-
33
- interface AudioTrackProcessorConstructorLike {
34
- new (init: { track: MediaStreamTrack }): AudioTrackProcessorLike;
35
- }
36
-
37
- interface AudioTrackGeneratorLike extends MediaStreamTrack {
38
- writable: WritableStream<AudioDataLike>;
39
- }
40
-
41
- interface AudioTrackGeneratorConstructorLike {
42
- new (init: { kind: "audio" }): AudioTrackGeneratorLike;
43
- }
44
-
45
- interface AudioRuntimeGlobals {
46
- AudioData?: AudioDataConstructorLike;
47
- MediaStreamTrackProcessor?: AudioTrackProcessorConstructorLike;
48
- MediaStreamTrackGenerator?: AudioTrackGeneratorConstructorLike;
49
- webkitAudioContext?: typeof AudioContext;
50
- }
51
-
52
- type AgentCodedError = Error & {
53
- agentCode?: string;
54
- retryable?: boolean;
55
- cause?: unknown;
56
- };
57
-
58
- export interface FixedAudioTrackOptions {
59
- onError?: (error: Error) => void;
60
- }
61
-
62
- /**
63
- * Converts a microphone MediaStreamTrack to a verifiable 16 kHz mono track
64
- * before it is handed to MediaRecorder. WebM/Opus remains the container/codec
65
- * used on the wire; this class only owns the decoded audio track conversion.
66
- */
67
- export class FixedAudioTrack {
68
- private readonly options: FixedAudioTrackOptions;
69
- private inputTrack: MediaStreamTrack | null = null;
70
- private reader: ReadableStreamDefaultReader<AudioDataLike> | null = null;
71
- private writer: WritableStreamDefaultWriter<AudioDataLike> | null = null;
72
- private generator: AudioTrackGeneratorLike | null = null;
73
- private audioContext: AudioContext | null = null;
74
- private audioSource: MediaStreamAudioSourceNode | null = null;
75
- private audioDestination: MediaStreamAudioDestinationNode | null = null;
76
- private pumpPromise: Promise<void> | null = null;
77
- private pumpFinished = false;
78
- private outputClosed = false;
79
- private draining = false;
80
- private inputSampleRate: number | null = null;
81
- private resampleBuffer: number[] = [];
82
- private resamplePosition = 0;
83
- private outputBuffer: number[] = [];
84
- private outputTimestamp: number | null = null;
85
- private startGeneration = 0;
86
-
87
- constructor(options: FixedAudioTrackOptions = {}) {
88
- this.options = options;
89
- }
90
-
91
- async start(inputTrack: MediaStreamTrack): Promise<MediaStream> {
92
- const startGeneration = ++this.startGeneration;
93
- this.inputTrack = inputTrack;
94
- this.inputSampleRate = null;
95
- this.resampleBuffer = [];
96
- this.resamplePosition = 0;
97
- this.outputBuffer = [];
98
- this.outputTimestamp = null;
99
- this.pumpFinished = false;
100
- this.outputClosed = false;
101
- this.draining = false;
102
- const globals = globalThis as typeof globalThis & AudioRuntimeGlobals;
103
- const ProcessorCtor = globals.MediaStreamTrackProcessor;
104
- const GeneratorCtor = globals.MediaStreamTrackGenerator;
105
- const AudioDataCtor = globals.AudioData;
106
- if (!ProcessorCtor || !GeneratorCtor || !AudioDataCtor) {
107
- return this.startWebAudio(inputTrack, globals, startGeneration);
108
- }
109
-
110
- const processor = new ProcessorCtor({ track: inputTrack });
111
- const generator = new GeneratorCtor({ kind: "audio" });
112
- const stream = new MediaStream([generator]);
113
- const outputTrack = stream.getAudioTracks()[0];
114
- const settings = outputTrack?.getSettings() as (MediaTrackSettings & {
115
- sampleRate?: number;
116
- channelCount?: number;
117
- }) | undefined;
118
- // Chrome does not expose generator settings until its first AudioData is
119
- // written. The format is nevertheless fixed by every AudioData created in
120
- // writeChunk; when settings are available, reject an implementation that
121
- // reports a different output track.
122
- if (
123
- (settings?.sampleRate !== undefined && settings.sampleRate !== TARGET_SAMPLE_RATE) ||
124
- (settings?.channelCount !== undefined && settings.channelCount !== TARGET_CHANNELS)
125
- ) {
126
- generator.stop();
127
- throw this.unsupported(
128
- `浏览器音频输出格式不符合要求: ${settings?.sampleRate || "unknown"} Hz / ${settings?.channelCount || "unknown"} channel`,
129
- );
130
- }
131
-
132
- try {
133
- this.reader = processor.readable.getReader();
134
- this.writer = generator.writable.getWriter();
135
- } catch (error) {
136
- generator.stop();
137
- if (inputTrack.readyState !== "ended") {
138
- inputTrack.stop();
139
- }
140
- this.inputTrack = null;
141
- throw error;
142
- }
143
- this.generator = generator;
144
- this.pumpPromise = this.pump(AudioDataCtor);
145
- return stream;
146
- }
147
-
148
- private async startWebAudio(
149
- inputTrack: MediaStreamTrack,
150
- globals: AudioRuntimeGlobals,
151
- startGeneration: number,
152
- ) {
153
- const AudioContextCtor = globalThis.AudioContext || globals.webkitAudioContext;
154
- if (!AudioContextCtor) {
155
- throw this.unsupported("当前浏览器不支持 16 kHz mono 音频处理");
156
- }
157
-
158
- let context: AudioContext | null = null;
159
- let source: MediaStreamAudioSourceNode | null = null;
160
- let destination: MediaStreamAudioDestinationNode | null = null;
161
- try {
162
- context = new AudioContextCtor({ sampleRate: TARGET_SAMPLE_RATE });
163
- this.audioContext = context;
164
- if (context.sampleRate !== TARGET_SAMPLE_RATE) {
165
- throw this.unsupported(`浏览器无法创建 16 kHz AudioContext: ${context.sampleRate} Hz`);
166
- }
167
- if (context.state === "suspended") {
168
- try {
169
- await context.resume();
170
- } catch (cause) {
171
- throw this.contextSuspended(cause);
172
- }
173
- }
174
- this.assertStartActive(startGeneration);
175
- if (context.state !== "running") {
176
- throw this.contextSuspended();
177
- }
178
-
179
- source = context.createMediaStreamSource(new MediaStream([inputTrack]));
180
- this.audioSource = source;
181
- destination = context.createMediaStreamDestination();
182
- this.audioDestination = destination;
183
- destination.channelCount = TARGET_CHANNELS;
184
- destination.channelCountMode = "explicit";
185
- destination.channelInterpretation = "speakers";
186
- source.connect(destination);
187
-
188
- const outputTrack = destination.stream.getAudioTracks()[0];
189
- if (!outputTrack) {
190
- throw new Error("Web Audio 未返回音频输出轨道");
191
- }
192
- this.validateOutputTrack(outputTrack);
193
- this.assertStartActive(startGeneration);
194
- return destination.stream;
195
- } catch (error) {
196
- source?.disconnect();
197
- destination?.stream.getTracks().forEach((track) => track.stop());
198
- if (context && context.state !== "closed") {
199
- await context.close().catch(() => undefined);
200
- }
201
- if (inputTrack.readyState !== "ended") {
202
- inputTrack.stop();
203
- }
204
- this.inputTrack = null;
205
- if (this.audioContext === context) {
206
- this.audioContext = null;
207
- this.audioSource = null;
208
- this.audioDestination = null;
209
- }
210
- throw this.normalizeWebAudioError(error);
211
- }
212
- }
213
-
214
- async stop() {
215
- await this.drain();
216
- if (this.audioContext) {
217
- await this.closeWebAudioOutput();
218
- return;
219
- }
220
- await this.closeOutput();
221
- }
222
-
223
- /** Stop input and flush decoded samples while keeping the output track live. */
224
- async drain() {
225
- this.startGeneration += 1;
226
- this.draining = true;
227
- this.inputTrack?.stop();
228
- this.inputTrack = null;
229
-
230
- if (this.audioContext) {
231
- return;
232
- }
233
-
234
- const pump = this.pumpPromise;
235
- if (pump) {
236
- const settled = pump.then(
237
- () => true,
238
- () => true,
239
- );
240
- const finished = await Promise.race([settled, this.delay(1000)]);
241
- if (!finished) {
242
- await Promise.race([
243
- this.reader?.cancel().catch(() => undefined),
244
- this.delay(100),
245
- ]);
246
- await Promise.race([
247
- this.writer?.abort(new Error("16 kHz mono 音频处理停止超时")).catch(() => undefined),
248
- this.delay(100),
249
- ]);
250
- this.stopGenerator();
251
- // Do not await an implementation that is stuck in writer.write(). The
252
- // rejection handler above keeps the eventual pump settlement observed.
253
- void settled;
254
- }
255
- }
256
- }
257
-
258
- private async closeOutput() {
259
- const writer = this.writer;
260
- this.outputClosed = true;
261
- if (writer && this.pumpFinished) {
262
- await Promise.race([
263
- writer.close().catch(() => undefined),
264
- this.delay(100),
265
- ]);
266
- } else {
267
- await Promise.race([
268
- writer?.abort(new Error("16 kHz mono 音频处理停止超时")).catch(() => undefined),
269
- this.delay(100),
270
- ]);
271
- }
272
- this.stopGenerator();
273
- if (writer && this.pumpFinished) {
274
- writer.releaseLock();
275
- }
276
- this.reader = null;
277
- this.writer = null;
278
- this.generator = null;
279
- this.pumpPromise = null;
280
- }
281
-
282
- private async closeWebAudioOutput() {
283
- const context = this.audioContext;
284
- const source = this.audioSource;
285
- const destination = this.audioDestination;
286
- this.audioContext = null;
287
- this.audioSource = null;
288
- this.audioDestination = null;
289
-
290
- source?.disconnect();
291
- destination?.disconnect();
292
- destination?.stream.getTracks().forEach((track) => track.stop());
293
- if (context && context.state !== "closed") {
294
- await context.close().catch(() => undefined);
295
- }
296
- }
297
-
298
- private async pump(AudioDataCtor: AudioDataConstructorLike) {
299
- const reader = this.reader;
300
- const writer = this.writer;
301
- if (!reader || !writer) {
302
- return;
303
- }
304
-
305
- try {
306
- let inputEnded = false;
307
- while (true) {
308
- const result = await reader.read();
309
- if (result.done) {
310
- inputEnded = true;
311
- break;
312
- }
313
- if (!result.value) {
314
- continue;
315
- }
316
-
317
- const audioData = result.value;
318
- try {
319
- this.validateAudioData(audioData);
320
- const mono = this.toMono(audioData);
321
- const chunks = this.resample(mono, audioData.sampleRate, false);
322
- if (this.outputTimestamp === null) {
323
- this.outputTimestamp = Number.isFinite(audioData.timestamp) ? audioData.timestamp : 0;
324
- }
325
- for (const chunk of chunks) {
326
- await this.writeChunk(writer, AudioDataCtor, chunk);
327
- }
328
- } finally {
329
- audioData.close();
330
- }
331
- }
332
-
333
- for (const chunk of this.resample(new Float32Array(), this.inputSampleRate || TARGET_SAMPLE_RATE, true)) {
334
- await this.writeChunk(writer, AudioDataCtor, chunk);
335
- }
336
- if (inputEnded && !this.draining && this.inputTrack?.readyState === "ended") {
337
- const error = new Error("麦克风音频轨道已结束");
338
- error.name = "AudioTransformError";
339
- this.options.onError?.(error);
340
- this.stopGenerator();
341
- }
342
- } catch (error) {
343
- const normalized = error instanceof Error ? error : new Error("16 kHz mono 音频处理失败");
344
- normalized.name = "AudioTransformError";
345
- this.options.onError?.(normalized);
346
- } finally {
347
- reader.releaseLock();
348
- this.pumpFinished = true;
349
- if (this.outputClosed) {
350
- writer.releaseLock();
351
- }
352
- }
353
- }
354
-
355
- private toMono(audioData: AudioDataLike) {
356
- const channels = Math.max(1, audioData.numberOfChannels);
357
- const mono = new Float32Array(audioData.numberOfFrames);
358
- for (let channel = 0; channel < channels; channel += 1) {
359
- const plane = new Float32Array(audioData.numberOfFrames);
360
- audioData.copyTo(plane, { format: "f32-planar", planeIndex: channel });
361
- for (let index = 0; index < plane.length; index += 1) {
362
- mono[index] += plane[index] / channels;
363
- }
364
- }
365
- return mono;
366
- }
367
-
368
- private resample(input: Float32Array, inputSampleRate: number, flush: boolean) {
369
- if (this.inputSampleRate === null) {
370
- this.inputSampleRate = inputSampleRate;
371
- } else if (this.inputSampleRate !== inputSampleRate) {
372
- throw new Error(`输入音频采样率发生变化: ${this.inputSampleRate} -> ${inputSampleRate}`);
373
- }
374
-
375
- for (const sample of input) {
376
- this.resampleBuffer.push(sample);
377
- }
378
- if (this.resampleBuffer.length > MAX_RESAMPLE_BUFFER_LENGTH) {
379
- throw new Error("输入音频重采样缓冲区超限");
380
- }
381
- if (flush && this.resampleBuffer.length > 0) {
382
- this.resampleBuffer.push(this.resampleBuffer[this.resampleBuffer.length - 1]);
383
- }
384
-
385
- const step = inputSampleRate / TARGET_SAMPLE_RATE;
386
- const chunks: Float32Array[] = [];
387
- while (this.resamplePosition + 1 < this.resampleBuffer.length) {
388
- const before = Math.floor(this.resamplePosition);
389
- const after = Math.min(before + 1, this.resampleBuffer.length - 1);
390
- const weight = this.resamplePosition - before;
391
- this.outputBuffer.push(
392
- this.resampleBuffer[before] * (1 - weight) + this.resampleBuffer[after] * weight,
393
- );
394
- this.resamplePosition += step;
395
-
396
- const consumed = Math.floor(this.resamplePosition);
397
- if (consumed > 0) {
398
- this.resampleBuffer.splice(0, consumed);
399
- this.resamplePosition -= consumed;
400
- }
401
-
402
- if (this.outputBuffer.length >= OUTPUT_FRAME_SIZE) {
403
- chunks.push(new Float32Array(this.outputBuffer.splice(0, OUTPUT_FRAME_SIZE)));
404
- }
405
- }
406
-
407
- if (flush && this.outputBuffer.length > 0) {
408
- chunks.push(new Float32Array(this.outputBuffer.splice(0)));
409
- }
410
- return chunks;
411
- }
412
-
413
- private async writeChunk(
414
- writer: WritableStreamDefaultWriter<AudioDataLike>,
415
- AudioDataCtor: AudioDataConstructorLike,
416
- samples: Float32Array,
417
- ) {
418
- if (!samples.length) {
419
- return;
420
- }
421
- const timestamp = this.outputTimestamp || 0;
422
- const audioData = new AudioDataCtor({
423
- format: "f32-planar",
424
- sampleRate: TARGET_SAMPLE_RATE,
425
- numberOfFrames: samples.length,
426
- numberOfChannels: TARGET_CHANNELS,
427
- timestamp,
428
- data: samples,
429
- });
430
- this.outputTimestamp = timestamp + Math.round((samples.length * 1_000_000) / TARGET_SAMPLE_RATE);
431
- await writer.write(audioData);
432
- audioData.close();
433
- const settings = this.generator?.getSettings() as
434
- | (MediaTrackSettings & { sampleRate?: number; channelCount?: number })
435
- | undefined;
436
- if (
437
- settings &&
438
- ((settings.sampleRate !== undefined && settings.sampleRate !== TARGET_SAMPLE_RATE) ||
439
- (settings.channelCount !== undefined && settings.channelCount !== TARGET_CHANNELS))
440
- ) {
441
- throw this.unsupported(
442
- `浏览器音频输出格式不符合要求: ${settings.sampleRate || "unknown"} Hz / ${settings.channelCount || "unknown"} channel`,
443
- );
444
- }
445
- }
446
-
447
- private validateAudioData(audioData: AudioDataLike) {
448
- if (
449
- !Number.isFinite(audioData.sampleRate) ||
450
- !Number.isInteger(audioData.sampleRate) ||
451
- audioData.sampleRate < MIN_INPUT_SAMPLE_RATE ||
452
- audioData.sampleRate > MAX_INPUT_SAMPLE_RATE
453
- ) {
454
- throw new Error(`输入音频采样率无效: ${audioData.sampleRate}`);
455
- }
456
- if (
457
- !Number.isInteger(audioData.numberOfChannels) ||
458
- audioData.numberOfChannels < 1 ||
459
- audioData.numberOfChannels > 8
460
- ) {
461
- throw new Error(`输入音频声道数无效: ${audioData.numberOfChannels}`);
462
- }
463
- if (
464
- !Number.isInteger(audioData.numberOfFrames) ||
465
- audioData.numberOfFrames < 1 ||
466
- audioData.numberOfFrames > MAX_INPUT_FRAMES_PER_READ
467
- ) {
468
- throw new Error(`输入音频帧数无效: ${audioData.numberOfFrames}`);
469
- }
470
- if (!Number.isFinite(audioData.timestamp) || Math.abs(audioData.timestamp) > 1e15) {
471
- throw new Error(`输入音频时间戳无效: ${audioData.timestamp}`);
472
- }
473
- }
474
-
475
- private delay(timeoutMs: number) {
476
- return new Promise<boolean>((resolve) => {
477
- globalThis.setTimeout(() => resolve(false), timeoutMs);
478
- });
479
- }
480
-
481
- private stopGenerator() {
482
- if (this.generator && this.generator.readyState !== "ended") {
483
- this.generator.stop();
484
- }
485
- }
486
-
487
- private unsupported(message: string, cause?: unknown) {
488
- const error = new Error(message) as AgentCodedError;
489
- error.name = "NotSupportedError";
490
- error.agentCode = "AUDIO_FIXED_TRACK_UNSUPPORTED";
491
- error.retryable = false;
492
- error.cause = cause;
493
- return error;
494
- }
495
-
496
- private contextSuspended(cause?: unknown) {
497
- const error = new Error("AudioContext 未运行,请在用户操作后重试") as AgentCodedError;
498
- error.name = "NotAllowedError";
499
- error.agentCode = "AUDIO_CONTEXT_SUSPENDED";
500
- error.retryable = true;
501
- error.cause = cause;
502
- return error;
503
- }
504
-
505
- private assertStartActive(startGeneration: number) {
506
- if (startGeneration !== this.startGeneration) {
507
- const error = new Error("16 kHz mono 音频处理启动已取消") as AgentCodedError;
508
- error.name = "AbortError";
509
- error.agentCode = "AUDIO_FIXED_TRACK_CANCELLED";
510
- error.retryable = true;
511
- throw error;
512
- }
513
- }
514
-
515
- private normalizeWebAudioError(error: unknown) {
516
- const normalized: AgentCodedError = error instanceof Error
517
- ? error
518
- : new Error(String(error));
519
- if (normalized.agentCode) {
520
- return normalized;
521
- }
522
- if (normalized.name === "NotSupportedError") {
523
- return this.unsupported(`浏览器无法初始化 16 kHz mono Web Audio: ${normalized.message}`, normalized);
524
- }
525
- const coded = new Error(`16 kHz mono Web Audio 初始化失败: ${normalized.message}`) as AgentCodedError;
526
- coded.name = normalized.name;
527
- coded.agentCode = "AUDIO_FIXED_TRACK_FAILED";
528
- coded.retryable = true;
529
- coded.cause = normalized;
530
- return coded;
531
- }
532
-
533
- private validateOutputTrack(outputTrack: MediaStreamTrack) {
534
- const settings = outputTrack.getSettings() as MediaTrackSettings & {
535
- sampleRate?: number;
536
- channelCount?: number;
537
- };
538
- if (
539
- (settings.sampleRate !== undefined && settings.sampleRate !== TARGET_SAMPLE_RATE) ||
540
- (settings.channelCount !== undefined && settings.channelCount !== TARGET_CHANNELS)
541
- ) {
542
- throw this.unsupported(
543
- `浏览器音频输出格式不符合要求: ${settings.sampleRate || "unknown"} Hz / ${settings.channelCount || "unknown"} channel`,
544
- );
545
- }
546
- }
547
- }