@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.
- package/bin/init-call.js +9 -0
- package/dist/index.browser.cjs +778 -1628
- package/dist/index.browser.cjs.map +1 -1
- package/dist/index.browser.full-bundle.min.js +16 -18
- package/dist/index.browser.full-bundle.min.js.map +1 -1
- package/dist/index.browser.mjs +780 -1630
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.cjs +778 -1628
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +173 -40
- package/dist/index.d.ts +173 -40
- package/dist/index.mjs +780 -1630
- package/dist/index.mjs.map +1 -1
- package/dist/wasm_worker.worker.mjs +1596 -0
- package/dist/wasm_worker.worker.mjs.map +1 -0
- package/package.json +2 -2
- package/public/ermis_call_node_wasm_bg.wasm +0 -0
- package/src/channel.ts +117 -44
- package/src/channel_state.ts +6 -1
- package/src/client.ts +198 -56
- package/src/ermis_call_node.ts +123 -55
- package/src/index.ts +2 -1
- package/src/media_stream_receiver.ts +103 -35
- package/src/media_stream_sender.ts +72 -7
- package/src/signal_message.ts +48 -23
- package/src/system_message.ts +169 -27
- package/src/types.ts +13 -0
- package/src/utils.ts +22 -3
- package/src/wasm/ermis_call_node_wasm.d.ts +80 -78
- package/src/wasm/ermis_call_node_wasm.js +1427 -1357
- package/src/wasm_worker.ts +219 -0
- package/src/wasm_worker_proxy.ts +244 -0
package/src/ermis_call_node.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ErmisChat } from './client';
|
|
2
|
-
import
|
|
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
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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<
|
|
170
|
+
private async initialize(): Promise<WasmWorkerProxy> {
|
|
159
171
|
try {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
this.callNode
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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('
|
|
282
|
+
this.onError('call_network_error');
|
|
262
283
|
}
|
|
263
284
|
} else {
|
|
264
285
|
if (error.response.data.ermis_code === 20) {
|
|
265
|
-
this.onError('
|
|
286
|
+
this.onError('call_recipient_busy');
|
|
266
287
|
} else {
|
|
267
|
-
const errMsg = error.response.data?.message ? error.response.data?.message : '
|
|
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('
|
|
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
|
|
397
|
-
const memberIds = Object.keys(
|
|
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
|
-
//
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
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:
|
|
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
|
-
|
|
6
|
-
const
|
|
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
|
-
//
|
|
141
|
-
console.log('♻️
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
this.audioDecoder?.
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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;
|