@ermis-network/ermis-chat-sdk 1.0.8 → 2.0.0

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.
@@ -1,5 +1,5 @@
1
1
  import { ErmisChat } from './client';
2
- import init, { ErmisCall } from './wasm/ermis_call_node_wasm';
2
+ import { WasmWorkerProxy } from './wasm_worker_proxy';
3
3
  import {
4
4
  CallAction,
5
5
  CallEventData,
@@ -16,6 +16,7 @@ import { MediaStreamReceiver } from './media_stream_receiver';
16
16
 
17
17
  export class ErmisCallNode<ErmisChatGenerics extends ExtendableGenerics = DefaultGenerics> {
18
18
  wasmPath: string;
19
+ workerPath: string;
19
20
 
20
21
  relayUrl = 'https://test-iroh.ermis.network.:8443';
21
22
 
@@ -31,15 +32,17 @@ export class ErmisCallNode<ErmisChatGenerics extends ExtendableGenerics = Defaul
31
32
  /** Type of call: 'audio' or 'video' */
32
33
  callType?: string;
33
34
 
34
- /** ID of the current user */
35
- userID?: string | undefined;
35
+ /** ID of the current user — always reads live value from client */
36
+ get userID(): string | undefined {
37
+ return this._client?.userID;
38
+ }
36
39
 
37
40
  /** Current status of the call */
38
41
  callStatus? = '';
39
42
 
40
43
  metadata?: Metadata;
41
44
 
42
- callNode: ErmisCall | null = null;
45
+ callNode: WasmWorkerProxy | null = null;
43
46
 
44
47
  /** Local media stream from user's camera/microphone */
45
48
  localStream?: MediaStream | null = null;
@@ -131,15 +134,22 @@ export class ErmisCallNode<ErmisChatGenerics extends ExtendableGenerics = Defaul
131
134
  public mediaSender: MediaStreamSender | null = null;
132
135
  public mediaReceiver: MediaStreamReceiver | null = null;
133
136
 
134
- constructor(client: ErmisChat<ErmisChatGenerics>, sessionID: string, wasmPath: string, relayUrl: string) {
137
+ constructor(
138
+ client: ErmisChat<ErmisChatGenerics>,
139
+ sessionID: string,
140
+ wasmPath: string,
141
+ relayUrl: string,
142
+ workerPath?: string,
143
+ ) {
135
144
  this._client = client;
136
145
  this.cid = '';
137
146
  this.callType = '';
138
147
  this.sessionID = sessionID;
139
- this.userID = client.userID;
148
+ // userID is now a getter — reads from this._client.userID directly
140
149
  this.metadata = {};
141
150
  this.wasmPath = wasmPath;
142
151
  this.relayUrl = relayUrl;
152
+ this.workerPath = workerPath || '/wasm_worker.worker.mjs';
143
153
 
144
154
  this.listenSocketEvents();
145
155
  this.setupDeviceChangeListener();
@@ -148,24 +158,32 @@ export class ErmisCallNode<ErmisChatGenerics extends ExtendableGenerics = Defaul
148
158
 
149
159
  private async loadWasm(): Promise<void> {
150
160
  try {
151
- await init(this.wasmPath);
161
+ // Tạo Worker proxy — WASM chạy hoàn toàn trên Worker thread
162
+ this.callNode = new WasmWorkerProxy(new URL(this.workerPath, window.location.origin));
163
+ await this.callNode.init(this.wasmPath);
152
164
  } catch (error) {
153
- console.error('Failed to load ErmisCall WASM module:', error);
165
+ console.error('Failed to load ErmisCall WASM Worker:', error);
154
166
  throw error;
155
167
  }
156
168
  }
157
169
 
158
- private async initialize(): Promise<ErmisCall> {
170
+ private async initialize(): Promise<WasmWorkerProxy> {
159
171
  try {
160
- const node = new ErmisCall();
161
- await node.spawn([this.relayUrl]);
162
- this.callNode = node;
172
+ // Re-create Worker nếu đã bị terminate bởi call trước
173
+ // (Worker mới nhưng dùng cached Blob URL + compiled WASM Module → rất nhanh)
174
+ if (!this.callNode) {
175
+ await this.loadWasm();
176
+ }
177
+
178
+ const proxy = this.callNode!;
179
+
180
+ await proxy.spawn([this.relayUrl]);
163
181
 
164
- // 1. Init Sender
165
- this.mediaSender = new MediaStreamSender(node as any);
182
+ // 1. Init Sender — proxy implements INodeCall
183
+ this.mediaSender = new MediaStreamSender(proxy as any);
166
184
 
167
- // 2. Init Receiver
168
- this.mediaReceiver = new MediaStreamReceiver(node as any, {
185
+ // 2. Init Receiver — proxy implements INodeCall
186
+ this.mediaReceiver = new MediaStreamReceiver(proxy as any, {
169
187
  onConnected: () => {
170
188
  this.setCallStatus(CallStatus.CONNECTED);
171
189
  this.connectCall();
@@ -207,7 +225,10 @@ export class ErmisCallNode<ErmisChatGenerics extends ExtendableGenerics = Defaul
207
225
  },
208
226
  });
209
227
 
210
- return node;
228
+ // Bắt đầu recv loop trong Worker
229
+ await proxy.startRecvLoop();
230
+
231
+ return proxy;
211
232
  } catch (error) {
212
233
  console.error('Failed to initialize Ermis SDK:', error);
213
234
  throw error;
@@ -258,13 +279,13 @@ export class ErmisCallNode<ErmisChatGenerics extends ExtendableGenerics = Defaul
258
279
  if (typeof this.onError === 'function') {
259
280
  if (error.code === 'ERR_NETWORK') {
260
281
  if (action === CallAction.CREATE_CALL) {
261
- this.onError('Unable to make the call. Please check your network connection');
282
+ this.onError('call_network_error');
262
283
  }
263
284
  } else {
264
285
  if (error.response.data.ermis_code === 20) {
265
- this.onError('Recipient was busy');
286
+ this.onError('call_recipient_busy');
266
287
  } else {
267
- const errMsg = error.response.data?.message ? error.response.data?.message : 'Call failed';
288
+ const errMsg = error.response.data?.message ? error.response.data?.message : 'call_failed';
268
289
  this.onError(errMsg);
269
290
  }
270
291
  }
@@ -356,7 +377,7 @@ export class ErmisCallNode<ErmisChatGenerics extends ExtendableGenerics = Defaul
356
377
 
357
378
  // No device found at all — report error
358
379
  if (typeof this.onError === 'function') {
359
- this.onError('No microphone or camera found. Please check your device.');
380
+ this.onError('call_no_devices');
360
381
  }
361
382
  return null;
362
383
  }
@@ -391,27 +412,41 @@ export class ErmisCallNode<ErmisChatGenerics extends ExtendableGenerics = Defaul
391
412
  private setUserInfo(cid: string | undefined, eventUserId: string | undefined) {
392
413
  if (!cid || !eventUserId) return;
393
414
 
394
- // Get caller and receiver userId from activeChannels
395
415
  const channel = cid ? this.getClient().activeChannels[cid] : undefined;
396
- const members = channel?.state?.members || {};
397
- const memberIds = Object.keys(members);
416
+ const stateMembers = channel?.state?.members || {};
417
+ const memberIds = Object.keys(stateMembers);
398
418
 
399
- // callerId is eventUserId, receiverId is the other user in the channel
400
419
  const callerId = eventUserId || '';
401
420
  const receiverId = memberIds.find((id) => id !== callerId) || '';
402
421
 
403
- // Get user info from client.state.users
404
- const callerUser = this.getClient().state.users[callerId];
405
- const receiverUser = this.getClient().state.users[receiverId];
422
+ // Try multiple sources for user info (in order of reliability):
423
+ // 1. channel.data.members (enriched array from watch/query — most reliable)
424
+ // 2. channel.state.members[id].user (may be overwritten by async event handlers)
425
+ // 3. client.state.users (may not be populated due to disabled updateUser in updateUserReference)
426
+ const dataMembers: any[] = (channel?.data as any)?.members || [];
427
+
428
+ const findUserFromDataMembers = (userId: string) => {
429
+ const member = dataMembers.find((m: any) => m.user_id === userId || m.user?.id === userId);
430
+ return member?.user;
431
+ };
432
+
433
+ const callerUser =
434
+ findUserFromDataMembers(callerId) ||
435
+ (stateMembers[callerId] as any)?.user ||
436
+ this.getClient().state.users[callerId];
437
+ const receiverUser =
438
+ findUserFromDataMembers(receiverId) ||
439
+ (stateMembers[receiverId] as any)?.user ||
440
+ this.getClient().state.users[receiverId];
406
441
 
407
442
  this.callerInfo = {
408
443
  id: callerId,
409
- name: callerUser?.name,
444
+ name: callerUser?.name || callerId,
410
445
  avatar: callerUser?.avatar || '',
411
446
  };
412
447
  this.receiverInfo = {
413
448
  id: receiverId,
414
- name: receiverUser?.name,
449
+ name: receiverUser?.name || receiverId,
415
450
  avatar: receiverUser?.avatar || '',
416
451
  };
417
452
  }
@@ -435,7 +470,6 @@ export class ErmisCallNode<ErmisChatGenerics extends ExtendableGenerics = Defaul
435
470
  this.callType = is_video ? 'video' : 'audio';
436
471
 
437
472
  this.setUserInfo(cid, eventUserId);
438
- this.setCallStatus(CallStatus.RINGING);
439
473
  this.cid = cid || '';
440
474
  this.metadata = metadata || {};
441
475
 
@@ -450,6 +484,8 @@ export class ErmisCallNode<ErmisChatGenerics extends ExtendableGenerics = Defaul
450
484
  });
451
485
  }
452
486
 
487
+ this.setCallStatus(CallStatus.RINGING);
488
+
453
489
  await this.startLocalStream();
454
490
  if (this.callStatus === CallStatus.ENDED) return;
455
491
 
@@ -457,11 +493,6 @@ export class ErmisCallNode<ErmisChatGenerics extends ExtendableGenerics = Defaul
457
493
  await this.initialize();
458
494
  }
459
495
 
460
- if (this.localStream && this.mediaSender && this.mediaReceiver) {
461
- this.mediaSender?.initEncoders(this.localStream);
462
- this.mediaReceiver?.initDecoders(this.callType);
463
- }
464
-
465
496
  if (eventUserId === this.userID) {
466
497
  // Set missCall timeout if no connection after 60s
467
498
  if (this.missCallTimeout) clearTimeout(this.missCallTimeout);
@@ -479,9 +510,20 @@ export class ErmisCallNode<ErmisChatGenerics extends ExtendableGenerics = Defaul
479
510
  }
480
511
 
481
512
  if (eventUserId !== this.userID && !this.isDestroyed) {
513
+ // Caller side: establish peer connection FIRST
482
514
  if (this.mediaReceiver && this.mediaSender) {
483
515
  await this.mediaReceiver.acceptConnection();
484
516
  await this.mediaSender.sendConnected();
517
+ }
518
+
519
+ // Then init encoders/decoders (safe to sendControlFrame now)
520
+ if (this.localStream && this.mediaSender && this.mediaReceiver && this.callType) {
521
+ this.mediaSender?.initEncoders(this.localStream);
522
+ this.mediaReceiver?.initDecoders(this.callType);
523
+ }
524
+
525
+ // Re-send configs after encoders have populated them
526
+ if (this.mediaSender) {
485
527
  await this.mediaSender.sendConfigs();
486
528
  }
487
529
  }
@@ -491,7 +533,7 @@ export class ErmisCallNode<ErmisChatGenerics extends ExtendableGenerics = Defaul
491
533
  case CallAction.REJECT_CALL:
492
534
  case CallAction.MISS_CALL:
493
535
  // this.setCallStatus(CallStatus.ENDED);
494
- this.destroy();
536
+ await this.destroy();
495
537
  break;
496
538
  }
497
539
  };
@@ -548,7 +590,7 @@ export class ErmisCallNode<ErmisChatGenerics extends ExtendableGenerics = Defaul
548
590
  this._client.on('message.updated', this.messageUpdatedHandler);
549
591
  }
550
592
 
551
- private cleanupCall() {
593
+ private async cleanupCall() {
552
594
  if (this.mediaSender) {
553
595
  this.mediaSender?.stop();
554
596
  this.mediaSender = null;
@@ -559,12 +601,12 @@ export class ErmisCallNode<ErmisChatGenerics extends ExtendableGenerics = Defaul
559
601
  }
560
602
 
561
603
  if (this.callNode) {
562
- this.callNode?.closeEndpoint();
563
-
564
- if (this.callStatus === CallStatus.CONNECTED) {
565
- this.callNode?.closeConnection();
604
+ try {
605
+ // Timeout protection: don't let terminate() hang forever
606
+ await Promise.race([this.callNode.terminate(), new Promise((resolve) => setTimeout(resolve, 1000))]);
607
+ } catch {
608
+ /* ignore — Worker may already be dead */
566
609
  }
567
-
568
610
  this.callNode = null;
569
611
  }
570
612
 
@@ -613,11 +655,11 @@ export class ErmisCallNode<ErmisChatGenerics extends ExtendableGenerics = Defaul
613
655
  this.setCallStatus(CallStatus.ENDED);
614
656
  }
615
657
 
616
- private destroy() {
658
+ public async destroy() {
617
659
  // if (this.signalHandler) this._client.off('signal', this.signalHandler);
618
660
  // if (this.connectionChangedHandler) this._client.off('connection.changed', this.connectionChangedHandler);
619
661
  // if (this.messageUpdatedHandler) this._client.off('message.updated', this.messageUpdatedHandler);
620
- this.cleanupCall();
662
+ await this.cleanupCall();
621
663
  }
622
664
 
623
665
  public async getDevices(): Promise<{ audioDevices: MediaDeviceInfo[]; videoDevices: MediaDeviceInfo[] }> {
@@ -652,9 +694,15 @@ export class ErmisCallNode<ErmisChatGenerics extends ExtendableGenerics = Defaul
652
694
  };
653
695
  }
654
696
 
697
+ public prefillUserInfo(cid: string) {
698
+ this.setUserInfo(cid, this.userID);
699
+ }
700
+
655
701
  public async createCall(callType: string, cid: string) {
656
702
  try {
657
703
  this.cid = cid;
704
+ this.callType = callType;
705
+ this.prefillUserInfo(cid);
658
706
 
659
707
  const address = await this.getLocalEndpointAddr();
660
708
 
@@ -674,10 +722,22 @@ export class ErmisCallNode<ErmisChatGenerics extends ExtendableGenerics = Defaul
674
722
  try {
675
723
  await this._sendSignal({ action: CallAction.ACCEPT_CALL });
676
724
 
725
+ // Receiver side: establish peer connection FIRST
677
726
  if (this.mediaSender) {
678
727
  const address = this.metadata?.address || '';
679
728
  await this.mediaSender.connect(address);
680
729
  }
730
+
731
+ // Then init encoders/decoders (safe to sendControlFrame now)
732
+ if (this.localStream && this.mediaSender && this.mediaReceiver) {
733
+ this.mediaSender?.initEncoders(this.localStream);
734
+ this.mediaReceiver?.initDecoders(this.callType || 'audio');
735
+ }
736
+
737
+ // Re-send configs after encoders have populated them
738
+ if (this.mediaSender) {
739
+ await this.mediaSender.sendConfigs();
740
+ }
681
741
  } catch (error) {
682
742
  console.error('Failed to accept call:', error);
683
743
  throw error;
@@ -816,7 +876,7 @@ export class ErmisCallNode<ErmisChatGenerics extends ExtendableGenerics = Defaul
816
876
  track.stop();
817
877
  this.localStream?.removeTrack(track);
818
878
  });
819
-
879
+
820
880
  // Add new camera track
821
881
  this.localStream.addTrack(cameraTrack);
822
882
  } else {
@@ -879,22 +939,22 @@ export class ErmisCallNode<ErmisChatGenerics extends ExtendableGenerics = Defaul
879
939
 
880
940
  if (!this.localStream) return false;
881
941
 
942
+ // Lấy lại cấu hình chuẩn để không mất khử ồn, channelCount, v.v.
943
+ const mediaConstraints = await this.getMediaConstraints();
944
+
882
945
  // Get new audio stream with selected device
883
946
  const newStream = await navigator.mediaDevices.getUserMedia({
884
- audio: { deviceId: { exact: deviceId } },
947
+ audio: mediaConstraints.audio,
885
948
  video: false,
886
949
  });
887
950
 
888
951
  const newAudioTrack = newStream.getAudioTracks()[0];
889
952
  const oldAudioTrack = this.localStream.getAudioTracks()[0];
890
953
 
891
- // Replace audio track in peer connection
892
- // if (this.peer && oldAudioTrack) {
893
- // const sender = this.peer.getSenders().find((s) => s.track === oldAudioTrack);
894
- // if (sender) {
895
- // await sender.replaceTrack(newAudioTrack);
896
- // }
897
- // }
954
+ // Replace audio track in custom encoder pipeline
955
+ if (this.mediaSender && newAudioTrack) {
956
+ await this.mediaSender.replaceAudioTrack(newAudioTrack);
957
+ }
898
958
 
899
959
  // Replace audio track in local stream
900
960
  if (oldAudioTrack) {
@@ -935,15 +995,23 @@ export class ErmisCallNode<ErmisChatGenerics extends ExtendableGenerics = Defaul
935
995
 
936
996
  if (!this.localStream) return false;
937
997
 
998
+ // Lấy lại cấu hình chuẩn để không mất độ phân giải
999
+ const mediaConstraints = await this.getMediaConstraints();
1000
+
938
1001
  // Get new video stream with selected device
939
1002
  const newStream = await navigator.mediaDevices.getUserMedia({
940
1003
  audio: false,
941
- video: { deviceId: { exact: deviceId } },
1004
+ video: mediaConstraints.video,
942
1005
  });
943
1006
 
944
1007
  const newVideoTrack = newStream.getVideoTracks()[0];
945
1008
  const oldVideoTrack = this.localStream.getVideoTracks()[0];
946
1009
 
1010
+ // Replace video track in custom encoder pipeline
1011
+ if (this.mediaSender && newVideoTrack) {
1012
+ await this.mediaSender.replaceVideoTrack(newVideoTrack);
1013
+ }
1014
+
947
1015
  // Replace video track in local stream
948
1016
  if (oldVideoTrack) {
949
1017
  this.localStream.removeTrack(oldVideoTrack);
package/src/index.ts CHANGED
@@ -10,7 +10,8 @@ export * from './ermis_call_node';
10
10
  export * from './auth';
11
11
  export { chatCodes, logChatPromiseExecution, formatMessage, createForwardMessagePayload } from './utils';
12
12
  export { parseSystemMessage } from './system_message';
13
+ export type { SystemMessageTranslations } from './system_message';
13
14
  export { parseSignalMessage, CallType } from './signal_message';
14
- export type { SignalMessageResult, CallTypeValue } from './signal_message';
15
+ export type { SignalMessageResult, CallTypeValue, SignalMessageTranslations } from './signal_message';
15
16
  export { normalizeFileName, getAttachmentCategory, isVideoFile, isHeicFile, buildAttachmentPayload } from './attachment_utils';
16
17
  export type { VoiceRecordingMeta } from './attachment_utils';
@@ -2,9 +2,8 @@ import { replaceCodecNumber } from './utils';
2
2
  import { FRAME_TYPE, IMediaReceiverEvents, INodeCall, VideoConfig } from './types';
3
3
  import { HEVCDecoderConfigurationRecord } from './hevc_decoder_config';
4
4
 
5
- // Các biến cấu hình Audio Sync
6
- const MAX_AUDIO_LATENCY = 0.1; // 100ms
7
- const MIN_BUFFER_AHEAD = 0.02; // 20ms
5
+ const MAX_AUDIO_LATENCY = 0.5; // 500ms
6
+ const MIN_BUFFER_AHEAD = 0.05; // 50ms
8
7
 
9
8
  export class MediaStreamReceiver {
10
9
  private videoDecoder: VideoDecoder | null = null;
@@ -15,10 +14,13 @@ export class MediaStreamReceiver {
15
14
 
16
15
  private audioContext: AudioContext | null = null;
17
16
  private mediaDestination: MediaStreamAudioDestinationNode | null = null;
17
+ private scheduledAudioNodes: AudioBufferSourceNode[] = [];
18
18
 
19
19
  private isWaitingForKeyFrame: boolean = true;
20
20
  private nextStartTime: number = 0;
21
21
  private lastVideoConfig: VideoConfig | null = null;
22
+ private lastVideoConfigStr: string = '';
23
+ private lastAudioConfigStr: string = '';
22
24
 
23
25
  private nodeCall: INodeCall;
24
26
  private events: IMediaReceiverEvents;
@@ -137,14 +139,17 @@ export class MediaStreamReceiver {
137
139
  this.isWaitingForKeyFrame = true;
138
140
 
139
141
  if (this.videoWriter) {
140
- // Chỉ hồi sinh nếu Writer vẫn còn sống
141
- console.log('♻️ Attempting to respawn VideoDecoder...');
142
- this.setupVideoDecoder();
143
- if (this.lastVideoConfig && this.videoDecoder) {
144
- try {
145
- this.videoDecoder.configure(this.lastVideoConfig);
146
- } catch (configErr) {}
147
- }
142
+ // Tránh hồi sinh ngay lập tức gây vòng lặp vô hạn (Infinite Loop) nếu stream bị hỏng nặng
143
+ console.log('♻️ Scheduled VideoDecoder respawn in 1000ms...');
144
+ setTimeout(() => {
145
+ if (!this.videoWriter) return;
146
+ this.setupVideoDecoder();
147
+ if (this.lastVideoConfig && this.videoDecoder) {
148
+ try {
149
+ this.videoDecoder.configure(this.lastVideoConfig);
150
+ } catch (configErr) { }
151
+ }
152
+ }, 1000);
148
153
  }
149
154
  },
150
155
  });
@@ -165,20 +170,28 @@ export class MediaStreamReceiver {
165
170
 
166
171
  // --- XỬ LÝ LATENCY & SYNC ---
167
172
  if (this.nextStartTime < currentTime) {
168
- this.nextStartTime = currentTime;
173
+ this.nextStartTime = currentTime + MIN_BUFFER_AHEAD;
169
174
  } else if (this.nextStartTime > currentTime + MAX_AUDIO_LATENCY) {
170
- // Nếu buffer quá lớn (latency cao), reset về thời điểm hiện tại
175
+ // Nếu buffer quá lớn (latency cao), reset về thời điểm hiện tại cộng khoảng đệm
171
176
  this.nextStartTime = currentTime + MIN_BUFFER_AHEAD;
177
+
178
+ // Dọn dẹp các audio node cũ đang được lên lịch để tránh phát đè (overlap) gây nổ/méo tiếng
179
+ this.scheduledAudioNodes.forEach((node) => {
180
+ try {
181
+ node.stop();
182
+ } catch (e) {
183
+ /* ignore */
184
+ }
185
+ });
186
+ this.scheduledAudioNodes = [];
172
187
  }
173
188
 
174
189
  const audioBuffer = this.audioContext.createBuffer(numberOfChannels, numberOfFrames, sampleRate);
175
- const size = numberOfChannels * numberOfFrames;
176
- const tempBuffer = new Float32Array(size);
177
-
178
- audioData.copyTo(tempBuffer, { planeIndex: 0, format: 'f32-planar' });
179
190
 
191
+ // Lấy data đúng cho từng channel đối với âm thanh stereo/đa kênh
180
192
  for (let ch = 0; ch < numberOfChannels; ch++) {
181
- const channelData = tempBuffer.subarray(ch * numberOfFrames, (ch + 1) * numberOfFrames);
193
+ const channelData = new Float32Array(numberOfFrames);
194
+ audioData.copyTo(channelData, { planeIndex: ch, format: 'f32-planar' });
182
195
  audioBuffer.copyToChannel(channelData, ch);
183
196
  }
184
197
 
@@ -186,6 +199,23 @@ export class MediaStreamReceiver {
186
199
  source.buffer = audioBuffer;
187
200
  source.connect(this.mediaDestination);
188
201
 
202
+ this.scheduledAudioNodes.push(source);
203
+
204
+ // Quick Fix: Cap the buffer size to prevent memory/CPU accumulation on jittery networks (e.g. 3G)
205
+ // If we have too many scheduled nodes, it means the network is jittery or the clock is drifting.
206
+ // Dropping oldest nodes prevents a total UI freeze after ~1 minute of instability.
207
+ if (this.scheduledAudioNodes.length > 100) {
208
+ const oldestNode = this.scheduledAudioNodes.shift();
209
+ try {
210
+ oldestNode?.stop();
211
+ oldestNode?.disconnect();
212
+ } catch (e) { /* ignore */ }
213
+ }
214
+
215
+ source.onended = () => {
216
+ this.scheduledAudioNodes = this.scheduledAudioNodes.filter((n) => n !== source);
217
+ };
218
+
189
219
  source.start(this.nextStartTime);
190
220
  this.nextStartTime += duration;
191
221
 
@@ -211,8 +241,17 @@ export class MediaStreamReceiver {
211
241
  // Vòng lặp chính xử lý dữ liệu
212
242
  public receiveLoop = async (): Promise<void> => {
213
243
  const textDecoder = new TextDecoder();
244
+ let lastYieldTime = Date.now();
214
245
 
215
246
  while (true) {
247
+ // Force a macro-task yield every 16ms (roughly every frame) to prevent Main Thread starvation.
248
+ // This is crucial because if nodeCall.asyncRecv() returns data from a local buffer,
249
+ // the 'await' might resolve as a micro-task, which blocks UI painting/interaction.
250
+ if (Date.now() - lastYieldTime > 16) {
251
+ await new Promise((resolve) => setTimeout(resolve, 0));
252
+ lastYieldTime = Date.now();
253
+ }
254
+
216
255
  try {
217
256
  if (!this.nodeCall) break;
218
257
 
@@ -249,6 +288,7 @@ export class MediaStreamReceiver {
249
288
  FRAME_TYPE.REQUEST_CONFIG,
250
289
  FRAME_TYPE.REQUEST_KEY_FRAME,
251
290
  FRAME_TYPE.END_CALL,
291
+ FRAME_TYPE.HEALTH_CALL,
252
292
  ] as number[]
253
293
  ).includes(frameType)
254
294
  ? 1
@@ -261,8 +301,15 @@ export class MediaStreamReceiver {
261
301
  case FRAME_TYPE.VIDEO_CONFIG: {
262
302
  try {
263
303
  const videoConfigStr = textDecoder.decode(payload);
304
+
305
+ // Deduplicate: Don't re-configure if config hasn't changed (saves CPU on unstable networks)
306
+ if (this.lastVideoConfigStr === videoConfigStr && this.videoDecoder?.state === 'configured') {
307
+ break;
308
+ }
309
+ this.lastVideoConfigStr = videoConfigStr;
264
310
  const videoConfig = JSON.parse(videoConfigStr);
265
- console.log('--videoConfig--', videoConfig);
311
+
312
+ console.log('videoConfig', videoConfig);
266
313
 
267
314
  // Setup Video Track Writer & Combine Streams
268
315
  if (!this.videoWriter) {
@@ -331,15 +378,27 @@ export class MediaStreamReceiver {
331
378
 
332
379
  // --- AUDIO CONFIG ---
333
380
  case FRAME_TYPE.AUDIO_CONFIG: {
334
- const audioConfig = JSON.parse(textDecoder.decode(payload));
335
- console.log('--audioConfig--', audioConfig);
336
-
337
- if (this.audioDecoder?.state !== 'closed') {
338
- this.audioDecoder?.configure({
339
- codec: audioConfig.codec,
340
- sampleRate: audioConfig.sampleRate,
341
- numberOfChannels: audioConfig.numberOfChannels,
342
- });
381
+ try {
382
+ const audioConfigStr = textDecoder.decode(payload);
383
+
384
+ // Deduplicate audio config
385
+ if (this.lastAudioConfigStr === audioConfigStr && this.audioDecoder?.state === 'configured') {
386
+ break;
387
+ }
388
+ this.lastAudioConfigStr = audioConfigStr;
389
+ const audioConfig = JSON.parse(audioConfigStr);
390
+
391
+ console.log('audioConfig', audioConfig);
392
+
393
+ if (this.audioDecoder?.state !== 'closed') {
394
+ this.audioDecoder?.configure({
395
+ codec: audioConfig.codec,
396
+ sampleRate: audioConfig.sampleRate,
397
+ numberOfChannels: audioConfig.numberOfChannels,
398
+ });
399
+ }
400
+ } catch (e) {
401
+ console.error('❌ Error processing AUDIO_CONFIG:', e);
343
402
  }
344
403
  break;
345
404
  }
@@ -352,13 +411,12 @@ export class MediaStreamReceiver {
352
411
 
353
412
  if (this.isWaitingForKeyFrame) {
354
413
  if (!isKeyFrame) break;
355
- console.log('✅ Resumed decoding at KeyFrame');
414
+ // console.log('✅ Resumed decoding at KeyFrame');
356
415
  this.isWaitingForKeyFrame = false;
357
416
  }
358
417
 
359
- if (!isKeyFrame && this.videoDecoder.decodeQueueSize > 15) {
360
- console.warn('⚠️ Queue > 15. Dropping & Waiting for KeyFrame...');
361
- // Nếu drop bất kỳ frame nào, ta phải chờ Key Frame tiếp theo mới decode được
418
+ // Stricter Backpressure: Nếu queue > 5, drop ngay lập tức các frame không quan trọng
419
+ if (!isKeyFrame && this.videoDecoder.decodeQueueSize > 5) {
362
420
  this.isWaitingForKeyFrame = true;
363
421
  break;
364
422
  }
@@ -458,13 +516,16 @@ export class MediaStreamReceiver {
458
516
  }
459
517
  break;
460
518
 
519
+ case FRAME_TYPE.HEALTH_CALL:
520
+ // Keep-alive ping from peer — silently acknowledge
521
+ break;
522
+
461
523
  default:
462
- console.warn('❓ Unknown frame type received:', frameType);
524
+ // Mute unknown frame logs to save CPU and RAM
463
525
  break;
464
526
  }
465
527
  } catch (error) {
466
- console.error('Stream loop error', error);
467
- // Có thể thêm delay nhỏ ở đây để tránh spam error nếu loop lỗi liên tục
528
+ // console.error('Stream loop error', error);
468
529
  await new Promise((r) => setTimeout(r, 200));
469
530
  }
470
531
  }
@@ -511,6 +572,13 @@ export class MediaStreamReceiver {
511
572
  this.audioContext = null;
512
573
  }
513
574
 
575
+ this.scheduledAudioNodes.forEach((node) => {
576
+ try {
577
+ node.stop();
578
+ } catch (e) {}
579
+ });
580
+ this.scheduledAudioNodes = [];
581
+
514
582
  // Reset các biến
515
583
  this.isWaitingForKeyFrame = true;
516
584
  this.mediaDestination = null;