react-jssip-kit 0.7.6 → 0.7.8

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/dist/index.cjs CHANGED
@@ -481,7 +481,6 @@ function createSessionHandlers(deps) {
481
481
  rtc,
482
482
  detachSessionHandlers,
483
483
  onSessionFailed,
484
- onSessionConfirmed,
485
484
  sessionId
486
485
  } = deps;
487
486
  return {
@@ -502,7 +501,7 @@ function createSessionHandlers(deps) {
502
501
  },
503
502
  confirmed: (e) => {
504
503
  emitter.emit("confirmed", e);
505
- onSessionConfirmed?.(sessionId);
504
+ deps.enableMicrophoneRecovery?.(sessionId);
506
505
  },
507
506
  ended: (e) => {
508
507
  emitter.emit("ended", e);
@@ -614,8 +613,8 @@ var WebRTCSessionController = class {
614
613
  }
615
614
  cleanup(stopTracks = true) {
616
615
  const pc = this.getPC();
616
+ const isClosed = pc?.connectionState === "closed" || pc?.signalingState === "closed";
617
617
  if (pc && typeof pc.getSenders === "function") {
618
- const isClosed = pc.connectionState === "closed" || pc.signalingState === "closed";
619
618
  if (!isClosed) {
620
619
  for (const s of pc.getSenders()) {
621
620
  try {
@@ -626,8 +625,14 @@ var WebRTCSessionController = class {
626
625
  }
627
626
  }
628
627
  if (stopTracks && this.mediaStream) {
629
- for (const t of this.mediaStream.getTracks())
628
+ const senderTracks = pc && !isClosed ? new Set(
629
+ pc.getSenders().map((s) => s.track).filter((t) => Boolean(t))
630
+ ) : null;
631
+ for (const t of this.mediaStream.getTracks()) {
632
+ if (senderTracks?.has(t))
633
+ continue;
630
634
  t.stop();
635
+ }
631
636
  }
632
637
  this.mediaStream = null;
633
638
  this.currentSession = null;
@@ -706,29 +711,14 @@ var WebRTCSessionController = class {
706
711
  var SessionManager = class {
707
712
  constructor() {
708
713
  this.entries = /* @__PURE__ */ new Map();
709
- this.pendingMediaQueue = [];
710
- this.pendingMediaTtlMs = 3e4;
711
- }
712
- setPendingMediaTtl(ms) {
713
- if (typeof ms === "number" && ms > 0)
714
- this.pendingMediaTtlMs = ms;
715
- }
716
- enqueueOutgoingMedia(stream) {
717
- this.pendingMediaQueue.push({ stream, addedAt: Date.now() });
718
- }
719
- dequeueOutgoingMedia() {
720
- const now = Date.now();
721
- while (this.pendingMediaQueue.length) {
722
- const next = this.pendingMediaQueue.shift();
723
- if (!next)
724
- break;
725
- if (now - next.addedAt <= this.pendingMediaTtlMs) {
726
- return next.stream;
727
- } else {
728
- next.stream.getTracks().forEach((t) => t.stop());
729
- }
714
+ }
715
+ stopMediaStream(stream) {
716
+ if (!stream)
717
+ return;
718
+ for (const t of stream.getTracks()) {
719
+ if (t.readyState !== "ended")
720
+ t.stop();
730
721
  }
731
- return null;
732
722
  }
733
723
  getOrCreateRtc(sessionId, session) {
734
724
  let entry = this.entries.get(sessionId);
@@ -770,6 +760,9 @@ var SessionManager = class {
770
760
  session: null,
771
761
  media: null
772
762
  };
763
+ if (entry.media && entry.media !== stream) {
764
+ this.stopMediaStream(entry.media);
765
+ }
773
766
  entry.media = stream;
774
767
  entry.rtc.setMediaStream(stream);
775
768
  this.entries.set(sessionId, entry);
@@ -799,15 +792,16 @@ var SessionManager = class {
799
792
  const entry = this.entries.get(sessionId);
800
793
  if (entry) {
801
794
  entry.rtc.cleanup();
795
+ this.stopMediaStream(entry.media);
802
796
  this.entries.delete(sessionId);
803
797
  }
804
798
  }
805
799
  cleanupAllSessions() {
806
800
  for (const [, entry] of this.entries.entries()) {
807
801
  entry.rtc.cleanup();
802
+ this.stopMediaStream(entry.media);
808
803
  }
809
804
  this.entries.clear();
810
- this.pendingMediaQueue = [];
811
805
  }
812
806
  answer(sessionId, options) {
813
807
  const rtc = this.getRtc(sessionId);
@@ -843,6 +837,165 @@ var SessionManager = class {
843
837
  }
844
838
  };
845
839
 
840
+ // src/jssip-lib/sip/debugLogging.ts
841
+ var describePc = (pc) => ({
842
+ connectionState: pc?.connectionState,
843
+ signalingState: pc?.signalingState,
844
+ iceConnectionState: pc?.iceConnectionState
845
+ });
846
+ var SipDebugLogger = class {
847
+ constructor() {
848
+ this.enabled = false;
849
+ this.statsStops = /* @__PURE__ */ new Map();
850
+ }
851
+ setEnabled(enabled) {
852
+ this.enabled = enabled;
853
+ if (!enabled) {
854
+ this.statsStops.forEach((stop) => stop());
855
+ this.statsStops.clear();
856
+ }
857
+ }
858
+ isEnabled() {
859
+ return this.enabled;
860
+ }
861
+ logLocalAudioError(sessionId, message, pc, extra) {
862
+ if (!this.enabled)
863
+ return;
864
+ console.error(message, {
865
+ sessionId,
866
+ pc: describePc(pc),
867
+ ...extra
868
+ });
869
+ void this.logOutboundStats(sessionId, pc, message);
870
+ }
871
+ logRemoteAudioError(sessionId, message, pc, extra) {
872
+ if (!this.enabled)
873
+ return;
874
+ console.error(message, {
875
+ sessionId,
876
+ pc: describePc(pc),
877
+ ...extra
878
+ });
879
+ void this.logInboundStats(sessionId, pc, message);
880
+ }
881
+ logMicRecoveryDrop(payload) {
882
+ if (!this.enabled)
883
+ return;
884
+ console.error("[sip] microphone dropped", payload);
885
+ }
886
+ startCallStatsLogging(sessionId, session) {
887
+ if (!this.enabled || this.statsStops.has(sessionId))
888
+ return;
889
+ let pc = session?.connection ?? null;
890
+ const onPeer = (data) => {
891
+ pc = data.peerconnection;
892
+ };
893
+ session.on?.("peerconnection", onPeer);
894
+ const intervalMs = 3e3;
895
+ const logStats = async () => {
896
+ if (!this.enabled || !pc?.getStats)
897
+ return;
898
+ try {
899
+ const report = await pc.getStats();
900
+ const { outboundAudio, inboundAudio } = collectAudioStats(report);
901
+ console.info("[sip] call stats", {
902
+ sessionId,
903
+ pc: describePc(pc),
904
+ outboundAudio,
905
+ inboundAudio
906
+ });
907
+ } catch (err) {
908
+ console.error("[sip] call stats failed", { sessionId, error: err });
909
+ }
910
+ };
911
+ const timer = setInterval(() => {
912
+ void logStats();
913
+ }, intervalMs);
914
+ void logStats();
915
+ const stop = () => {
916
+ clearInterval(timer);
917
+ session.off?.("peerconnection", onPeer);
918
+ this.statsStops.delete(sessionId);
919
+ };
920
+ this.statsStops.set(sessionId, stop);
921
+ }
922
+ stopCallStatsLogging(sessionId) {
923
+ const stop = this.statsStops.get(sessionId);
924
+ if (stop)
925
+ stop();
926
+ }
927
+ async logOutboundStats(sessionId, pc, context) {
928
+ if (!pc?.getStats)
929
+ return;
930
+ try {
931
+ const report = await pc.getStats();
932
+ const { outboundAudio } = collectAudioStats(report);
933
+ if (outboundAudio.length) {
934
+ console.info("[sip] outgoing audio stats", {
935
+ sessionId,
936
+ context,
937
+ outboundAudio
938
+ });
939
+ }
940
+ } catch (err) {
941
+ console.error("[sip] outgoing audio stats failed", {
942
+ sessionId,
943
+ context,
944
+ error: err
945
+ });
946
+ }
947
+ }
948
+ async logInboundStats(sessionId, pc, context) {
949
+ if (!pc?.getStats)
950
+ return;
951
+ try {
952
+ const report = await pc.getStats();
953
+ const { inboundAudio } = collectAudioStats(report);
954
+ if (inboundAudio.length) {
955
+ console.error("[sip] incoming audio stats", {
956
+ sessionId,
957
+ context,
958
+ inboundAudio
959
+ });
960
+ }
961
+ } catch (err) {
962
+ console.error("[sip] incoming audio stats failed", {
963
+ sessionId,
964
+ context,
965
+ error: err
966
+ });
967
+ }
968
+ }
969
+ };
970
+ var sipDebugLogger = new SipDebugLogger();
971
+ function collectAudioStats(report) {
972
+ const outboundAudio = [];
973
+ const inboundAudio = [];
974
+ report.forEach((stat) => {
975
+ const kind = stat.kind ?? stat.mediaType;
976
+ if (stat.type === "outbound-rtp" && kind === "audio") {
977
+ outboundAudio.push({
978
+ id: stat.id,
979
+ packetsSent: stat.packetsSent,
980
+ bytesSent: stat.bytesSent,
981
+ jitter: stat.jitter,
982
+ roundTripTime: stat.roundTripTime
983
+ });
984
+ }
985
+ if (stat.type === "inbound-rtp" && kind === "audio") {
986
+ inboundAudio.push({
987
+ id: stat.id,
988
+ packetsReceived: stat.packetsReceived,
989
+ packetsLost: stat.packetsLost,
990
+ bytesReceived: stat.bytesReceived,
991
+ jitter: stat.jitter,
992
+ roundTripTime: stat.roundTripTime
993
+ });
994
+ }
995
+ });
996
+ return { outboundAudio, inboundAudio };
997
+ }
998
+
846
999
  // src/jssip-lib/sip/sessionLifecycle.ts
847
1000
  var SessionLifecycle = class {
848
1001
  constructor(deps) {
@@ -853,37 +1006,48 @@ var SessionLifecycle = class {
853
1006
  this.attachSessionHandlers = deps.attachSessionHandlers;
854
1007
  this.getMaxSessionCount = deps.getMaxSessionCount;
855
1008
  }
1009
+ setDebugEnabled(enabled) {
1010
+ sipDebugLogger.setEnabled(enabled);
1011
+ }
856
1012
  handleNewRTCSession(e) {
857
1013
  const session = e.session;
858
- const sessionId = String(session?.id ?? crypto.randomUUID?.() ?? Date.now());
1014
+ const sessionId = String(
1015
+ session?.id ?? crypto.randomUUID?.() ?? Date.now()
1016
+ );
859
1017
  const currentSessions = this.state.getState().sessions;
860
1018
  if (currentSessions.length >= this.getMaxSessionCount()) {
861
1019
  try {
862
- session.terminate?.({ status_code: 486, reason_phrase: "Busy Here" });
1020
+ session.terminate?.({
1021
+ status_code: 486,
1022
+ reason_phrase: "Busy Here"
1023
+ });
863
1024
  } catch {
864
1025
  }
865
1026
  if (e.originator === "remote") {
866
1027
  this.emit("missed", e);
1028
+ } else {
1029
+ this.emitError(
1030
+ "max session count reached",
1031
+ "MAX_SESSIONS_REACHED",
1032
+ "max session count reached"
1033
+ );
867
1034
  }
868
- this.emitError("max session count reached", "MAX_SESSIONS_REACHED", "max session count reached");
869
1035
  return;
870
1036
  }
871
- const outgoingMedia = e.originator === "local" ? this.sessionManager.dequeueOutgoingMedia() : null;
872
- if (outgoingMedia)
873
- this.sessionManager.setSessionMedia(sessionId, outgoingMedia);
874
1037
  const rtc = this.sessionManager.getOrCreateRtc(sessionId, session);
875
- if (outgoingMedia)
876
- rtc.setMediaStream(outgoingMedia);
877
1038
  this.sessionManager.setSession(sessionId, session);
878
1039
  this.attachSessionHandlers(sessionId, session);
879
- holdOtherSessions(
880
- this.state,
881
- sessionId,
882
- (id) => {
883
- const otherRtc = this.sessionManager.getRtc(id);
884
- otherRtc?.hold();
885
- }
886
- );
1040
+ this.attachCallStatsLogging(sessionId, session);
1041
+ if (e.originator === "local" && !rtc.mediaStream) {
1042
+ this.bindLocalOutgoingAudio(sessionId, session);
1043
+ }
1044
+ if (e.originator === "remote") {
1045
+ this.bindRemoteIncomingAudio(sessionId, session);
1046
+ }
1047
+ holdOtherSessions(this.state, sessionId, (id) => {
1048
+ const otherRtc = this.sessionManager.getRtc(id);
1049
+ otherRtc?.hold();
1050
+ });
887
1051
  const sdpHasVideo = e.request?.body && e.request.body.toString().includes("m=video") || session?.connection?.getReceivers?.()?.some((r) => r.track?.kind === "video");
888
1052
  upsertSessionState(this.state, sessionId, {
889
1053
  direction: e.originator,
@@ -895,6 +1059,530 @@ var SessionLifecycle = class {
895
1059
  });
896
1060
  this.emit("newRTCSession", e);
897
1061
  }
1062
+ bindLocalOutgoingAudio(sessionId, session) {
1063
+ const maxAttempts = 50;
1064
+ const retryDelayMs = 500;
1065
+ let attempts = 0;
1066
+ let retryScheduled = false;
1067
+ let retryTimer = null;
1068
+ let stopped = false;
1069
+ let exhausted = false;
1070
+ let exhaustedCheckUsed = false;
1071
+ let attachedPc = null;
1072
+ const logLocalAudioError = (message, pc, extra) => {
1073
+ sipDebugLogger.logLocalAudioError(sessionId, message, pc, extra);
1074
+ };
1075
+ const tryBindFromPc = (pc) => {
1076
+ if (stopped || !pc || this.sessionManager.getRtc(sessionId)?.mediaStream) {
1077
+ return false;
1078
+ }
1079
+ const audioSender = pc?.getSenders?.()?.find((s) => s.track?.kind === "audio");
1080
+ const audioTrack = audioSender?.track;
1081
+ if (!audioTrack) {
1082
+ logLocalAudioError(
1083
+ "[sip] outgoing audio bind failed: no audio track",
1084
+ pc
1085
+ );
1086
+ return false;
1087
+ }
1088
+ const outgoingStream = new MediaStream([audioTrack]);
1089
+ this.sessionManager.setSessionMedia(sessionId, outgoingStream);
1090
+ return true;
1091
+ };
1092
+ const onPcStateChange = () => {
1093
+ if (stopped)
1094
+ return;
1095
+ if (exhausted) {
1096
+ if (exhaustedCheckUsed)
1097
+ return;
1098
+ exhaustedCheckUsed = true;
1099
+ if (tryBindFromPc(attachedPc))
1100
+ stopRetry();
1101
+ return;
1102
+ }
1103
+ if (tryBindFromPc(attachedPc))
1104
+ stopRetry();
1105
+ };
1106
+ const attachPcListeners = (pc) => {
1107
+ if (!pc || pc === attachedPc)
1108
+ return;
1109
+ if (attachedPc) {
1110
+ attachedPc.removeEventListener?.(
1111
+ "signalingstatechange",
1112
+ onPcStateChange
1113
+ );
1114
+ attachedPc.removeEventListener?.(
1115
+ "connectionstatechange",
1116
+ onPcStateChange
1117
+ );
1118
+ attachedPc.removeEventListener?.(
1119
+ "iceconnectionstatechange",
1120
+ onPcStateChange
1121
+ );
1122
+ }
1123
+ attachedPc = pc;
1124
+ attachedPc.addEventListener?.("signalingstatechange", onPcStateChange);
1125
+ attachedPc.addEventListener?.("connectionstatechange", onPcStateChange);
1126
+ attachedPc.addEventListener?.("iceconnectionstatechange", onPcStateChange);
1127
+ };
1128
+ const clearRetryTimer = () => {
1129
+ if (!retryTimer)
1130
+ return;
1131
+ clearTimeout(retryTimer);
1132
+ retryTimer = null;
1133
+ };
1134
+ const stopRetry = () => {
1135
+ if (stopped)
1136
+ return;
1137
+ stopped = true;
1138
+ clearRetryTimer();
1139
+ if (attachedPc) {
1140
+ attachedPc.removeEventListener?.(
1141
+ "signalingstatechange",
1142
+ onPcStateChange
1143
+ );
1144
+ attachedPc.removeEventListener?.(
1145
+ "connectionstatechange",
1146
+ onPcStateChange
1147
+ );
1148
+ attachedPc.removeEventListener?.(
1149
+ "iceconnectionstatechange",
1150
+ onPcStateChange
1151
+ );
1152
+ attachedPc = null;
1153
+ }
1154
+ session.off?.("peerconnection", onPeer);
1155
+ session.off?.("confirmed", onConfirmed);
1156
+ session.off?.("ended", stopRetry);
1157
+ session.off?.("failed", stopRetry);
1158
+ };
1159
+ const scheduleRetry = (pc) => {
1160
+ if (stopped || retryScheduled || exhausted)
1161
+ return;
1162
+ if (attempts >= maxAttempts) {
1163
+ logLocalAudioError(
1164
+ "[sip] outgoing audio bind failed: max retries reached",
1165
+ pc,
1166
+ { attempts }
1167
+ );
1168
+ exhausted = true;
1169
+ clearRetryTimer();
1170
+ return;
1171
+ }
1172
+ if (!pc) {
1173
+ logLocalAudioError(
1174
+ "[sip] outgoing audio bind failed: missing peerconnection",
1175
+ pc
1176
+ );
1177
+ }
1178
+ retryScheduled = true;
1179
+ attempts += 1;
1180
+ retryTimer = setTimeout(() => {
1181
+ retryScheduled = false;
1182
+ retryTimer = null;
1183
+ if (tryBindFromPc(pc)) {
1184
+ stopRetry();
1185
+ return;
1186
+ }
1187
+ scheduleRetry(pc);
1188
+ }, retryDelayMs);
1189
+ };
1190
+ const onPeer = (data) => {
1191
+ if (stopped)
1192
+ return;
1193
+ attachPcListeners(data.peerconnection);
1194
+ if (exhausted) {
1195
+ if (exhaustedCheckUsed)
1196
+ return;
1197
+ exhaustedCheckUsed = true;
1198
+ if (tryBindFromPc(data.peerconnection))
1199
+ stopRetry();
1200
+ return;
1201
+ }
1202
+ if (tryBindFromPc(data.peerconnection)) {
1203
+ stopRetry();
1204
+ return;
1205
+ }
1206
+ scheduleRetry(data.peerconnection);
1207
+ };
1208
+ const onConfirmed = () => {
1209
+ if (stopped)
1210
+ return;
1211
+ const currentPc = session?.connection ?? attachedPc;
1212
+ if (exhausted) {
1213
+ if (exhaustedCheckUsed)
1214
+ return;
1215
+ exhaustedCheckUsed = true;
1216
+ if (tryBindFromPc(currentPc))
1217
+ stopRetry();
1218
+ return;
1219
+ }
1220
+ if (tryBindFromPc(currentPc)) {
1221
+ stopRetry();
1222
+ return;
1223
+ }
1224
+ scheduleRetry(currentPc);
1225
+ };
1226
+ const existingPc = session?.connection;
1227
+ if (!tryBindFromPc(existingPc)) {
1228
+ if (existingPc) {
1229
+ attachPcListeners(existingPc);
1230
+ scheduleRetry(existingPc);
1231
+ }
1232
+ session.on?.("peerconnection", onPeer);
1233
+ }
1234
+ session.on?.("confirmed", onConfirmed);
1235
+ session.on?.("ended", stopRetry);
1236
+ session.on?.("failed", stopRetry);
1237
+ }
1238
+ bindRemoteIncomingAudio(sessionId, session) {
1239
+ const maxAttempts = 50;
1240
+ const retryDelayMs = 500;
1241
+ let attempts = 0;
1242
+ let retryScheduled = false;
1243
+ let retryTimer = null;
1244
+ let stopped = false;
1245
+ let exhausted = false;
1246
+ let exhaustedCheckUsed = false;
1247
+ let attachedPc = null;
1248
+ let attachedTrack = null;
1249
+ const logRemoteAudioError = (message, pc, extra) => {
1250
+ sipDebugLogger.logRemoteAudioError(sessionId, message, pc, extra);
1251
+ };
1252
+ const logMissingReceiver = (pc, note) => {
1253
+ logRemoteAudioError(
1254
+ "[sip] incoming audio bind failed: no remote track",
1255
+ pc,
1256
+ { note }
1257
+ );
1258
+ };
1259
+ const getRemoteAudioTrack = (pc) => {
1260
+ const receiver = pc?.getReceivers?.()?.find((r) => r.track?.kind === "audio");
1261
+ return receiver?.track ?? null;
1262
+ };
1263
+ const attachTrackListeners = (track) => {
1264
+ if (!track || track === attachedTrack)
1265
+ return;
1266
+ if (attachedTrack) {
1267
+ attachedTrack.removeEventListener?.("ended", onRemoteEnded);
1268
+ attachedTrack.removeEventListener?.("mute", onRemoteMuted);
1269
+ }
1270
+ attachedTrack = track;
1271
+ attachedTrack.addEventListener?.("ended", onRemoteEnded);
1272
+ attachedTrack.addEventListener?.("mute", onRemoteMuted);
1273
+ };
1274
+ const checkRemoteTrack = (pc) => {
1275
+ if (stopped || !pc)
1276
+ return false;
1277
+ const track = getRemoteAudioTrack(pc);
1278
+ if (!track)
1279
+ return false;
1280
+ attachTrackListeners(track);
1281
+ if (track.readyState !== "live") {
1282
+ logRemoteAudioError("[sip] incoming audio track not live", pc, {
1283
+ trackState: track.readyState
1284
+ });
1285
+ }
1286
+ return true;
1287
+ };
1288
+ const onRemoteEnded = () => {
1289
+ logRemoteAudioError("[sip] incoming audio track ended", attachedPc);
1290
+ };
1291
+ const onRemoteMuted = () => {
1292
+ logRemoteAudioError("[sip] incoming audio track muted", attachedPc);
1293
+ };
1294
+ const onPcStateChange = () => {
1295
+ if (stopped)
1296
+ return;
1297
+ if (exhausted) {
1298
+ if (exhaustedCheckUsed)
1299
+ return;
1300
+ exhaustedCheckUsed = true;
1301
+ if (checkRemoteTrack(attachedPc))
1302
+ stopRetry();
1303
+ return;
1304
+ }
1305
+ if (checkRemoteTrack(attachedPc))
1306
+ stopRetry();
1307
+ };
1308
+ const attachPcListeners = (pc) => {
1309
+ if (!pc || pc === attachedPc)
1310
+ return;
1311
+ if (attachedPc) {
1312
+ attachedPc.removeEventListener?.(
1313
+ "signalingstatechange",
1314
+ onPcStateChange
1315
+ );
1316
+ attachedPc.removeEventListener?.(
1317
+ "connectionstatechange",
1318
+ onPcStateChange
1319
+ );
1320
+ attachedPc.removeEventListener?.(
1321
+ "iceconnectionstatechange",
1322
+ onPcStateChange
1323
+ );
1324
+ attachedPc.removeEventListener?.("track", onTrack);
1325
+ }
1326
+ attachedPc = pc;
1327
+ attachedPc.addEventListener?.("signalingstatechange", onPcStateChange);
1328
+ attachedPc.addEventListener?.("connectionstatechange", onPcStateChange);
1329
+ attachedPc.addEventListener?.("iceconnectionstatechange", onPcStateChange);
1330
+ attachedPc.addEventListener?.("track", onTrack);
1331
+ };
1332
+ const clearRetryTimer = () => {
1333
+ if (!retryTimer)
1334
+ return;
1335
+ clearTimeout(retryTimer);
1336
+ retryTimer = null;
1337
+ };
1338
+ const stopRetry = () => {
1339
+ if (stopped)
1340
+ return;
1341
+ stopped = true;
1342
+ clearRetryTimer();
1343
+ if (attachedPc) {
1344
+ attachedPc.removeEventListener?.(
1345
+ "signalingstatechange",
1346
+ onPcStateChange
1347
+ );
1348
+ attachedPc.removeEventListener?.(
1349
+ "connectionstatechange",
1350
+ onPcStateChange
1351
+ );
1352
+ attachedPc.removeEventListener?.(
1353
+ "iceconnectionstatechange",
1354
+ onPcStateChange
1355
+ );
1356
+ attachedPc.removeEventListener?.("track", onTrack);
1357
+ attachedPc = null;
1358
+ }
1359
+ if (attachedTrack) {
1360
+ attachedTrack.removeEventListener?.("ended", onRemoteEnded);
1361
+ attachedTrack.removeEventListener?.("mute", onRemoteMuted);
1362
+ attachedTrack = null;
1363
+ }
1364
+ session.off?.("peerconnection", onPeer);
1365
+ session.off?.("confirmed", onConfirmed);
1366
+ session.off?.("ended", stopRetry);
1367
+ session.off?.("failed", stopRetry);
1368
+ };
1369
+ const scheduleRetry = (pc) => {
1370
+ if (stopped || retryScheduled || exhausted)
1371
+ return;
1372
+ if (attempts >= maxAttempts) {
1373
+ logRemoteAudioError(
1374
+ "[sip] incoming audio bind failed: max retries reached",
1375
+ pc,
1376
+ { attempts }
1377
+ );
1378
+ exhausted = true;
1379
+ clearRetryTimer();
1380
+ return;
1381
+ }
1382
+ retryScheduled = true;
1383
+ attempts += 1;
1384
+ retryTimer = setTimeout(() => {
1385
+ retryScheduled = false;
1386
+ retryTimer = null;
1387
+ if (checkRemoteTrack(pc)) {
1388
+ stopRetry();
1389
+ return;
1390
+ }
1391
+ if (!pc)
1392
+ logMissingReceiver(pc, "missing peerconnection");
1393
+ scheduleRetry(pc);
1394
+ }, retryDelayMs);
1395
+ };
1396
+ const onTrack = () => {
1397
+ if (stopped)
1398
+ return;
1399
+ if (exhausted) {
1400
+ if (exhaustedCheckUsed)
1401
+ return;
1402
+ exhaustedCheckUsed = true;
1403
+ if (checkRemoteTrack(attachedPc))
1404
+ stopRetry();
1405
+ return;
1406
+ }
1407
+ if (checkRemoteTrack(attachedPc))
1408
+ stopRetry();
1409
+ };
1410
+ const onPeer = (data) => {
1411
+ if (stopped)
1412
+ return;
1413
+ attachPcListeners(data.peerconnection);
1414
+ if (exhausted) {
1415
+ if (exhaustedCheckUsed)
1416
+ return;
1417
+ exhaustedCheckUsed = true;
1418
+ if (checkRemoteTrack(data.peerconnection))
1419
+ stopRetry();
1420
+ return;
1421
+ }
1422
+ if (checkRemoteTrack(data.peerconnection)) {
1423
+ stopRetry();
1424
+ return;
1425
+ }
1426
+ scheduleRetry(data.peerconnection);
1427
+ };
1428
+ const onConfirmed = () => {
1429
+ if (stopped)
1430
+ return;
1431
+ const currentPc = session?.connection ?? attachedPc;
1432
+ if (exhausted) {
1433
+ if (exhaustedCheckUsed)
1434
+ return;
1435
+ exhaustedCheckUsed = true;
1436
+ if (checkRemoteTrack(currentPc))
1437
+ stopRetry();
1438
+ return;
1439
+ }
1440
+ if (checkRemoteTrack(currentPc)) {
1441
+ stopRetry();
1442
+ return;
1443
+ }
1444
+ logMissingReceiver(currentPc, "confirmed without remote track");
1445
+ scheduleRetry(currentPc);
1446
+ };
1447
+ const existingPc = session?.connection;
1448
+ if (!checkRemoteTrack(existingPc)) {
1449
+ if (existingPc) {
1450
+ attachPcListeners(existingPc);
1451
+ scheduleRetry(existingPc);
1452
+ }
1453
+ session.on?.("peerconnection", onPeer);
1454
+ }
1455
+ session.on?.("confirmed", onConfirmed);
1456
+ session.on?.("ended", stopRetry);
1457
+ session.on?.("failed", stopRetry);
1458
+ }
1459
+ attachCallStatsLogging(sessionId, session) {
1460
+ const onConfirmed = () => {
1461
+ sipDebugLogger.startCallStatsLogging(sessionId, session);
1462
+ };
1463
+ const onEnd = () => {
1464
+ sipDebugLogger.stopCallStatsLogging(sessionId);
1465
+ };
1466
+ session.on?.("confirmed", onConfirmed);
1467
+ session.on?.("ended", onEnd);
1468
+ session.on?.("failed", onEnd);
1469
+ }
1470
+ };
1471
+
1472
+ // src/jssip-lib/sip/micRecovery.ts
1473
+ var MicRecoveryManager = class {
1474
+ constructor(deps) {
1475
+ this.enabled = false;
1476
+ this.defaults = {
1477
+ intervalMs: 2e3,
1478
+ maxRetries: Infinity
1479
+ };
1480
+ this.active = /* @__PURE__ */ new Map();
1481
+ this.deps = deps;
1482
+ }
1483
+ configure(config) {
1484
+ if (typeof config.enabled === "boolean") {
1485
+ this.enabled = config.enabled;
1486
+ }
1487
+ if (typeof config.intervalMs === "number") {
1488
+ this.defaults.intervalMs = config.intervalMs;
1489
+ }
1490
+ if (typeof config.maxRetries === "number") {
1491
+ this.defaults.maxRetries = config.maxRetries;
1492
+ }
1493
+ }
1494
+ enable(sessionId, options = {}) {
1495
+ if (!this.enabled)
1496
+ return () => {
1497
+ };
1498
+ this.disable(sessionId);
1499
+ const intervalMs = options.intervalMs ?? this.defaults.intervalMs;
1500
+ const maxRetries = options.maxRetries ?? this.defaults.maxRetries;
1501
+ let retries = 0;
1502
+ let stopped = false;
1503
+ const startedAt = Date.now();
1504
+ const warmupMs = Math.max(intervalMs * 2, 2e3);
1505
+ const tick = async () => {
1506
+ if (stopped || retries >= maxRetries)
1507
+ return;
1508
+ const rtc = this.deps.getRtc(sessionId);
1509
+ const session2 = this.deps.getSession(sessionId);
1510
+ if (!rtc || !session2)
1511
+ return;
1512
+ const sessionState = this.deps.getSessionState(sessionId);
1513
+ if (sessionState?.muted)
1514
+ return;
1515
+ const stream = rtc.mediaStream;
1516
+ const track = stream?.getAudioTracks?.()[0];
1517
+ const pc2 = session2?.connection;
1518
+ const sender = pc2?.getSenders?.()?.find((s) => s.track?.kind === "audio");
1519
+ if (!track && !sender)
1520
+ return;
1521
+ if (Date.now() - startedAt < warmupMs)
1522
+ return;
1523
+ if (pc2?.connectionState === "new" || pc2?.connectionState === "connecting" || pc2?.iceConnectionState === "new" || pc2?.iceConnectionState === "checking") {
1524
+ return;
1525
+ }
1526
+ const trackLive = track?.readyState === "live";
1527
+ const senderLive = sender?.track?.readyState === "live";
1528
+ if (trackLive && senderLive)
1529
+ return;
1530
+ sipDebugLogger.logMicRecoveryDrop({
1531
+ sessionId,
1532
+ trackLive,
1533
+ senderLive
1534
+ });
1535
+ retries += 1;
1536
+ if (trackLive && !senderLive && track) {
1537
+ await rtc.replaceAudioTrack(track);
1538
+ return;
1539
+ }
1540
+ let nextStream;
1541
+ try {
1542
+ const deviceId = track?.getSettings?.().deviceId ?? sender?.track?.getSettings?.().deviceId;
1543
+ nextStream = await this.deps.requestMicrophoneStream(deviceId);
1544
+ } catch (err) {
1545
+ console.warn("[sip] mic recovery failed to get stream", err);
1546
+ return;
1547
+ }
1548
+ const nextTrack = nextStream.getAudioTracks()[0];
1549
+ if (!nextTrack)
1550
+ return;
1551
+ await rtc.replaceAudioTrack(nextTrack);
1552
+ this.deps.setSessionMedia(sessionId, nextStream);
1553
+ };
1554
+ const timer = setInterval(() => {
1555
+ void tick();
1556
+ }, intervalMs);
1557
+ void tick();
1558
+ const session = this.deps.getSession(sessionId);
1559
+ const pc = session?.connection;
1560
+ const onIceChange = () => {
1561
+ const state = pc?.iceConnectionState;
1562
+ if (state === "failed" || state === "disconnected")
1563
+ void tick();
1564
+ };
1565
+ pc?.addEventListener?.("iceconnectionstatechange", onIceChange);
1566
+ const stop = () => {
1567
+ stopped = true;
1568
+ clearInterval(timer);
1569
+ pc?.removeEventListener?.("iceconnectionstatechange", onIceChange);
1570
+ };
1571
+ this.active.set(sessionId, { stop });
1572
+ return stop;
1573
+ }
1574
+ disable(sessionId) {
1575
+ const entry = this.active.get(sessionId);
1576
+ if (!entry)
1577
+ return false;
1578
+ entry.stop();
1579
+ this.active.delete(sessionId);
1580
+ return true;
1581
+ }
1582
+ cleanupAll() {
1583
+ this.active.forEach((entry) => entry.stop());
1584
+ this.active.clear();
1585
+ }
898
1586
  };
899
1587
 
900
1588
  // src/jssip-lib/sip/client.ts
@@ -907,12 +1595,6 @@ var SipClient = class extends EventTargetEmitter {
907
1595
  this.sessionHandlers = /* @__PURE__ */ new Map();
908
1596
  this.maxSessionCount = Infinity;
909
1597
  this.sessionManager = new SessionManager();
910
- this.micRecovery = /* @__PURE__ */ new Map();
911
- this.micRecoveryEnabled = false;
912
- this.micRecoveryDefaults = {
913
- intervalMs: 2e3,
914
- maxRetries: Infinity
915
- };
916
1598
  this.errorHandler = options.errorHandler ?? new SipErrorHandler({
917
1599
  formatter: options.formatError,
918
1600
  messages: options.errorMessages
@@ -934,6 +1616,14 @@ var SipClient = class extends EventTargetEmitter {
934
1616
  attachSessionHandlers: (sessionId, session) => this.attachSessionHandlers(sessionId, session),
935
1617
  getMaxSessionCount: () => this.maxSessionCount
936
1618
  });
1619
+ this.micRecovery = new MicRecoveryManager({
1620
+ getRtc: (sessionId) => this.sessionManager.getRtc(sessionId),
1621
+ getSession: (sessionId) => this.sessionManager.getSession(sessionId),
1622
+ getSessionState: (sessionId) => this.stateStore.getState().sessions.find((s) => s.id === sessionId),
1623
+ setSessionMedia: (sessionId, stream) => this.sessionManager.setSessionMedia(sessionId, stream),
1624
+ emitError: (raw, code, fallback) => this.emitError(raw, code, fallback),
1625
+ requestMicrophoneStream: (deviceId) => this.requestMicrophoneStreamInternal(deviceId)
1626
+ });
937
1627
  if (typeof window !== "undefined") {
938
1628
  window.sipDebugBridge = (debug) => this.setDebug(debug ?? true);
939
1629
  }
@@ -950,20 +1640,17 @@ var SipClient = class extends EventTargetEmitter {
950
1640
  micRecoveryIntervalMs,
951
1641
  micRecoveryMaxRetries,
952
1642
  maxSessionCount,
953
- pendingMediaTtlMs,
954
1643
  ...uaCfg
955
1644
  } = config;
956
1645
  this.maxSessionCount = typeof maxSessionCount === "number" ? maxSessionCount : Infinity;
957
- this.micRecoveryEnabled = Boolean(enableMicRecovery);
958
- if (typeof micRecoveryIntervalMs === "number") {
959
- this.micRecoveryDefaults.intervalMs = micRecoveryIntervalMs;
960
- }
961
- if (typeof micRecoveryMaxRetries === "number") {
962
- this.micRecoveryDefaults.maxRetries = micRecoveryMaxRetries;
963
- }
964
- this.sessionManager.setPendingMediaTtl(pendingMediaTtlMs);
1646
+ this.micRecovery.configure({
1647
+ enabled: Boolean(enableMicRecovery),
1648
+ intervalMs: micRecoveryIntervalMs,
1649
+ maxRetries: micRecoveryMaxRetries
1650
+ });
965
1651
  const debug = cfgDebug ?? this.getPersistedDebug() ?? this.debugPattern;
966
1652
  this.userAgent.start(uri, password, uaCfg, { debug });
1653
+ this.lifecycle.setDebugEnabled(Boolean(debug));
967
1654
  this.attachUAHandlers();
968
1655
  this.attachBeforeUnload();
969
1656
  this.syncDebugInspector(debug);
@@ -981,10 +1668,15 @@ var SipClient = class extends EventTargetEmitter {
981
1668
  call(target, callOptions = {}) {
982
1669
  try {
983
1670
  const opts = this.ensureMediaConstraints(callOptions);
984
- if (opts.mediaStream)
985
- this.sessionManager.enqueueOutgoingMedia(opts.mediaStream);
986
1671
  const ua = this.userAgent.getUA();
987
- ua?.call(target, opts);
1672
+ const session = ua?.call(target, opts);
1673
+ if (session && opts.mediaStream) {
1674
+ const sessionId = String(session?.id ?? "");
1675
+ if (sessionId) {
1676
+ this.sessionManager.setSessionMedia(sessionId, opts.mediaStream);
1677
+ this.sessionManager.setSession(sessionId, session);
1678
+ }
1679
+ }
988
1680
  } catch (e) {
989
1681
  const err = this.emitError(e, "CALL_FAILED", "call failed");
990
1682
  this.cleanupAllSessions();
@@ -1039,6 +1731,7 @@ var SipClient = class extends EventTargetEmitter {
1039
1731
  setDebug(debug) {
1040
1732
  this.debugPattern = debug;
1041
1733
  this.userAgent.setDebug(debug);
1734
+ this.lifecycle.setDebugEnabled(Boolean(debug));
1042
1735
  this.syncDebugInspector(debug);
1043
1736
  }
1044
1737
  attachSessionHandlers(sessionId, session) {
@@ -1074,14 +1767,13 @@ var SipClient = class extends EventTargetEmitter {
1074
1767
  cleanupSession(sessionId, session) {
1075
1768
  const targetSession = session ?? this.sessionManager.getSession(sessionId) ?? this.sessionManager.getRtc(sessionId)?.currentSession;
1076
1769
  this.detachSessionHandlers(sessionId, targetSession);
1077
- this.disableMicrophoneRecovery(sessionId);
1770
+ this.micRecovery.disable(sessionId);
1078
1771
  this.sessionManager.cleanupSession(sessionId);
1079
1772
  removeSessionState(this.stateStore, sessionId);
1080
1773
  }
1081
1774
  cleanupAllSessions() {
1082
1775
  this.sessionManager.cleanupAllSessions();
1083
- this.micRecovery.forEach((entry) => entry.stop());
1084
- this.micRecovery.clear();
1776
+ this.micRecovery.cleanupAll();
1085
1777
  this.sessionHandlers.clear();
1086
1778
  this.stateStore.setState({
1087
1779
  sessions: [],
@@ -1095,12 +1787,9 @@ var SipClient = class extends EventTargetEmitter {
1095
1787
  state: this.stateStore,
1096
1788
  rtc,
1097
1789
  detachSessionHandlers: () => this.cleanupSession(sessionId, session),
1790
+ emitError: (raw, code, fallback) => this.emitError(raw, code, fallback),
1098
1791
  onSessionFailed: (err, event) => this.onSessionFailed(err, event),
1099
- onSessionConfirmed: (confirmedSessionId) => {
1100
- if (this.micRecoveryEnabled) {
1101
- this.enableMicrophoneRecovery(confirmedSessionId);
1102
- }
1103
- },
1792
+ enableMicrophoneRecovery: (confirmedSessionId) => this.micRecovery.enable(confirmedSessionId),
1104
1793
  sessionId
1105
1794
  });
1106
1795
  }
@@ -1208,102 +1897,6 @@ var SipClient = class extends EventTargetEmitter {
1208
1897
  setSessionMedia(sessionId, stream) {
1209
1898
  this.sessionManager.setSessionMedia(sessionId, stream);
1210
1899
  }
1211
- enableMicrophoneRecovery(sessionId, options = {}) {
1212
- const resolved = this.resolveExistingSessionId(sessionId);
1213
- if (!resolved)
1214
- return () => {
1215
- };
1216
- this.disableMicrophoneRecovery(resolved);
1217
- const intervalMs = options.intervalMs ?? this.micRecoveryDefaults.intervalMs;
1218
- const maxRetries = options.maxRetries ?? this.micRecoveryDefaults.maxRetries;
1219
- let retries = 0;
1220
- let stopped = false;
1221
- const startedAt = Date.now();
1222
- const warmupMs = Math.max(intervalMs * 2, 2e3);
1223
- const tick = async () => {
1224
- if (stopped || retries >= maxRetries)
1225
- return;
1226
- const rtc = this.sessionManager.getRtc(resolved);
1227
- const session2 = this.sessionManager.getSession(resolved);
1228
- if (!rtc || !session2)
1229
- return;
1230
- const sessionState = this.stateStore.getState().sessions.find((s) => s.id === resolved);
1231
- if (sessionState?.muted)
1232
- return;
1233
- const stream = rtc.mediaStream;
1234
- const track = stream?.getAudioTracks?.()[0];
1235
- const pc2 = session2?.connection;
1236
- const sender = pc2?.getSenders?.().find((s) => s.track?.kind === "audio");
1237
- if (!track && !sender)
1238
- return;
1239
- if (Date.now() - startedAt < warmupMs)
1240
- return;
1241
- if (pc2?.connectionState === "new" || pc2?.connectionState === "connecting" || pc2?.iceConnectionState === "new" || pc2?.iceConnectionState === "checking") {
1242
- return;
1243
- }
1244
- const trackLive = track?.readyState === "live";
1245
- const senderLive = sender?.track?.readyState === "live";
1246
- if (trackLive && senderLive)
1247
- return;
1248
- this.emitError(
1249
- {
1250
- cause: "microphone dropped",
1251
- trackLive,
1252
- senderLive
1253
- },
1254
- "MICROPHONE_DROPPED",
1255
- "microphone dropped"
1256
- );
1257
- retries += 1;
1258
- if (trackLive && !senderLive && track) {
1259
- await rtc.replaceAudioTrack(track);
1260
- return;
1261
- }
1262
- let nextStream;
1263
- try {
1264
- const deviceId = track?.getSettings?.().deviceId ?? sender?.track?.getSettings?.().deviceId;
1265
- nextStream = await this.requestMicrophoneStreamInternal(deviceId);
1266
- } catch (err) {
1267
- console.warn("[sip] mic recovery failed to get stream", err);
1268
- return;
1269
- }
1270
- const nextTrack = nextStream.getAudioTracks()[0];
1271
- if (!nextTrack)
1272
- return;
1273
- await rtc.replaceAudioTrack(nextTrack);
1274
- this.sessionManager.setSessionMedia(resolved, nextStream);
1275
- };
1276
- const timer = setInterval(() => {
1277
- void tick();
1278
- }, intervalMs);
1279
- void tick();
1280
- const session = this.sessionManager.getSession(resolved);
1281
- const pc = session?.connection;
1282
- const onIceChange = () => {
1283
- const state = pc?.iceConnectionState;
1284
- if (state === "failed" || state === "disconnected")
1285
- void tick();
1286
- };
1287
- pc?.addEventListener?.("iceconnectionstatechange", onIceChange);
1288
- const stop = () => {
1289
- stopped = true;
1290
- clearInterval(timer);
1291
- pc?.removeEventListener?.("iceconnectionstatechange", onIceChange);
1292
- };
1293
- this.micRecovery.set(resolved, { stop });
1294
- return stop;
1295
- }
1296
- disableMicrophoneRecovery(sessionId) {
1297
- const resolved = this.resolveExistingSessionId(sessionId);
1298
- if (!resolved)
1299
- return false;
1300
- const entry = this.micRecovery.get(resolved);
1301
- if (!entry)
1302
- return false;
1303
- entry.stop();
1304
- this.micRecovery.delete(resolved);
1305
- return true;
1306
- }
1307
1900
  switchCameraSession(sessionId, track) {
1308
1901
  if (!this.sessionExists(sessionId))
1309
1902
  return false;
@@ -1352,14 +1945,17 @@ var SipClient = class extends EventTargetEmitter {
1352
1945
  syncDebugInspector(debug) {
1353
1946
  if (typeof window === "undefined")
1354
1947
  return;
1355
- this.toggleStateLogger(Boolean(debug));
1948
+ const persisted = this.getPersistedDebug();
1949
+ const effectiveDebug = debug ?? persisted ?? this.debugPattern;
1950
+ this.lifecycle.setDebugEnabled(Boolean(effectiveDebug));
1951
+ this.toggleStateLogger(Boolean(effectiveDebug));
1356
1952
  const win = window;
1357
1953
  const disabledInspector = () => {
1358
1954
  console.warn("SIP debug inspector disabled; enable debug to inspect.");
1359
1955
  return null;
1360
1956
  };
1361
- win.sipState = () => debug ? this.stateStore.getState() : disabledInspector();
1362
- win.sipSessions = () => debug ? this.getSessions() : disabledInspector();
1957
+ win.sipState = () => effectiveDebug ? this.stateStore.getState() : disabledInspector();
1958
+ win.sipSessions = () => effectiveDebug ? this.getSessions() : disabledInspector();
1363
1959
  }
1364
1960
  toggleStateLogger(enabled) {
1365
1961
  if (!enabled) {
@@ -1372,22 +1968,10 @@ var SipClient = class extends EventTargetEmitter {
1372
1968
  let prev = this.stateStore.getState();
1373
1969
  console.info("[sip][state]", { initial: true }, prev);
1374
1970
  this.stateLogOff = this.stateStore.onChange((next) => {
1375
- const changes = this.diffState(prev, next);
1376
- if (changes) {
1377
- console.info("[sip][state]", changes, next);
1378
- }
1971
+ console.info("[sip][state]", next);
1379
1972
  prev = next;
1380
1973
  });
1381
1974
  }
1382
- diffState(prev, next) {
1383
- const changed = {};
1384
- for (const key of Object.keys(next)) {
1385
- if (prev[key] !== next[key]) {
1386
- changed[key] = { from: prev[key], to: next[key] };
1387
- }
1388
- }
1389
- return Object.keys(changed).length ? changed : null;
1390
- }
1391
1975
  getPersistedDebug() {
1392
1976
  if (typeof window === "undefined")
1393
1977
  return void 0;
@@ -1469,8 +2053,6 @@ function useSipActions() {
1469
2053
  getSessionIds: () => client.getSessionIds(),
1470
2054
  getSessions: () => client.getSessions(),
1471
2055
  setSessionMedia: (...args) => client.setSessionMedia(...args),
1472
- enableMicrophoneRecovery: (...args) => client.enableMicrophoneRecovery(...args),
1473
- disableMicrophoneRecovery: (...args) => client.disableMicrophoneRecovery(...args),
1474
2056
  switchCamera: (...args) => client.switchCameraSession(...args),
1475
2057
  enableVideo: (...args) => client.enableVideoSession(...args),
1476
2058
  disableVideo: (...args) => client.disableVideoSession(...args)
@@ -1544,6 +2126,7 @@ function createCallPlayer(audioEl) {
1544
2126
  return () => session.off("peerconnection", onPeer);
1545
2127
  };
1546
2128
  function bindToSession(session) {
2129
+ clearAudioStream(audioEl.srcObject);
1547
2130
  if (session?.direction === "outgoing" && session.connection instanceof RTCPeerConnection) {
1548
2131
  cleanupTrackListener = dispose(cleanupTrackListener);
1549
2132
  cleanupTrackListener = attachTracks(session.connection);
@@ -1560,6 +2143,7 @@ function createCallPlayer(audioEl) {
1560
2143
  const e = payload?.data;
1561
2144
  cleanupSessionPeerListener = dispose(cleanupSessionPeerListener);
1562
2145
  cleanupTrackListener = dispose(cleanupTrackListener);
2146
+ clearAudioStream(audioEl.srcObject);
1563
2147
  if (!e?.session)
1564
2148
  return;
1565
2149
  cleanupSessionPeerListener = listenSessionPeerconnection(e.session);