comparison libervia/web/pages/calls/_browser/webrtc.py @ 1566:e65d2ef1ded4

browser (calls/webrtc): send ICE candidates when received: - ICE candidates gathering is not waited for anymore - if session is not ready, candidates are buffered and replayed when suitable
author Goffi <goffi@goffi.org>
date Mon, 21 Aug 2023 15:36:09 +0200
parents d282dbdd5ffd
children 9ba532041a8e
comparison
equal deleted inserted replaced
1565:d282dbdd5ffd 1566:e65d2ef1ded4
9 import jid 9 import jid
10 10
11 log.warning = log.warn 11 log.warning = log.warn
12 profile = window.profile or "" 12 profile = window.profile or ""
13 bridge = Bridge() 13 bridge = Bridge()
14 GATHER_TIMEOUT = 10000
15 14
16 15
17 class WebRTC: 16 class WebRTC:
18 def __init__( 17 def __init__(
19 self, 18 self,
73 """Inits or resets the instance variables to their default state.""" 72 """Inits or resets the instance variables to their default state."""
74 self._peer_connection = None 73 self._peer_connection = None
75 self._media_types = None 74 self._media_types = None
76 self._media_types_inv = None 75 self._media_types_inv = None
77 self._callee = None 76 self._callee = None
77 self.ufrag = None
78 self.pwd = None
78 self.sid = None 79 self.sid = None
79 self.local_candidates = None 80 self.local_candidates = None
80 self.remote_stream = None 81 self.remote_stream = None
81 self.candidates_buffer = { 82 self.remote_candidates_buffer = {
82 "audio": {"candidates": []}, 83 "audio": {"candidates": []},
83 "video": {"candidates": []}, 84 "video": {"candidates": []},
84 } 85 }
86 self.local_candidates_buffer = {}
85 self.media_candidates = {} 87 self.media_candidates = {}
86 self.candidates_gathered = aio.Future()
87 if self.on_reset_cb is not None: 88 if self.on_reset_cb is not None:
88 self.on_reset_cb() 89 self.on_reset_cb()
89 90
90 async def _populate_video_devices(self): 91 async def _populate_video_devices(self):
91 devices = await window.navigator.mediaDevices.enumerateDevices() 92 devices = await window.navigator.mediaDevices.enumerateDevices()
136 for index, m_type in self.media_types.items(): 137 for index, m_type in self.media_types.items():
137 if m_type == media_type: 138 if m_type == media_type:
138 return index 139 return index
139 raise ValueError(f"Media type '{media_type}' not found") 140 raise ValueError(f"Media type '{media_type}' not found")
140 141
141 def extract_pwd_ufrag(self, sdp): 142 def extract_ufrag_pwd(self, sdp: str) -> tuple[str, str]:
142 """Retrieves ICE password and user fragment for SDP offer. 143 """Retrieves ICE password and user fragment for SDP offer.
143 144
144 @param sdp: The Session Description Protocol offer string. 145 @param sdp: The Session Description Protocol offer string.
146 @return: ufrag and pwd
147 @raise ValueError: Can't extract ufrag and password
145 """ 148 """
146 ufrag_line = re.search(r"ice-ufrag:(\S+)", sdp) 149 ufrag_line = re.search(r"ice-ufrag:(\S+)", sdp)
147 pwd_line = re.search(r"ice-pwd:(\S+)", sdp) 150 pwd_line = re.search(r"ice-pwd:(\S+)", sdp)
148 151
149 if ufrag_line and pwd_line: 152 if ufrag_line and pwd_line:
150 return ufrag_line.group(1), pwd_line.group(1) 153 ufrag = self.ufrag = ufrag_line.group(1)
154 pwd = self.pwd = pwd_line.group(1)
155 return ufrag, pwd
151 else: 156 else:
152 log.error(f"SDP with missing ice-ufrag or ice-pwd:\n{sdp}") 157 log.error(f"SDP with missing ice-ufrag or ice-pwd:\n{sdp}")
153 raise ValueError("Can't extract ice-ufrag and ice-pwd from SDP") 158 raise ValueError("Can't extract ice-ufrag and ice-pwd from SDP")
154 159
155 def extract_fingerprint_data(self, sdp): 160 def extract_fingerprint_data(self, sdp):
240 f"Can't find media type.\n{event.candidate=}\n{self._media_types=}" 245 f"Can't find media type.\n{event.candidate=}\n{self._media_types=}"
241 ) 246 )
242 return 247 return
243 self.media_candidates.setdefault(media_type, []).append(parsed_candidate) 248 self.media_candidates.setdefault(media_type, []).append(parsed_candidate)
244 log.debug(f"ICE candidate [{media_type}]: {event.candidate.candidate}") 249 log.debug(f"ICE candidate [{media_type}]: {event.candidate.candidate}")
250 if self.sid is None:
251 log.debug("buffering candidate")
252 self.local_candidates_buffer.setdefault(media_type, []).append(
253 parsed_candidate
254 )
255 else:
256 ufrag, pwd = self.extract_ufrag_pwd(
257 self._peer_connection.localDescription.sdp
258 )
259
260 ice_data = {"ufrag": ufrag, "pwd": pwd, "candidates": [parsed_candidate]}
261 aio.run(
262 bridge.ice_candidates_add(
263 self.sid, json.dumps({media_type: ice_data})
264 )
265 )
266
245 else: 267 else:
246 log.debug("All ICE candidates gathered") 268 log.debug("All ICE candidates gathered")
247 269
248 def on_ice_connection_state_change(self, event): 270 def on_ice_connection_state_change(self, event):
249 """Log ICE connection change, mainly used for debugging""" 271 """Log ICE connection change, mainly used for debugging"""
292 """ 314 """
293 connection = event.target 315 connection = event.target
294 log.debug(f"on_ice_gathering_state_change {connection.iceGatheringState=}") 316 log.debug(f"on_ice_gathering_state_change {connection.iceGatheringState=}")
295 if connection.iceGatheringState == "complete": 317 if connection.iceGatheringState == "complete":
296 log.info("ICE candidates gathering done") 318 log.info("ICE candidates gathering done")
297 self.candidates_gathered.set_result(None)
298 319
299 async def _create_peer_connection( 320 async def _create_peer_connection(
300 self, 321 self,
301 ): 322 ):
302 """Creates peer connection""" 323 """Creates peer connection"""
493 ) 514 )
494 515
495 if video_sender: 516 if video_sender:
496 await video_sender.replaceTrack(new_video_tracks[0]) 517 await video_sender.replaceTrack(new_video_tracks[0])
497 518
498
499 async def _gather_ice_candidates(self, is_initiator: bool, remote_candidates=None): 519 async def _gather_ice_candidates(self, is_initiator: bool, remote_candidates=None):
500 """Get ICE candidates and wait to have them all before returning them 520 """Get ICE candidates and wait to have them all before returning them
501 521
502 @param is_initiator: Boolean indicating if the user is the initiator of the connection 522 @param is_initiator: Boolean indicating if the user is the initiator of the connection
503 @param remote_candidates: Remote ICE candidates, if any 523 @param remote_candidates: Remote ICE candidates, if any
506 raise Exception( 526 raise Exception(
507 "The peer connection must be created before gathering ICE candidates!" 527 "The peer connection must be created before gathering ICE candidates!"
508 ) 528 )
509 529
510 self.media_candidates.clear() 530 self.media_candidates.clear()
511 gather_timeout = timer.set_timeout(
512 lambda: self.candidates_gathered.set_exception(
513 errors.TimeoutError("ICE gathering time out")
514 ),
515 GATHER_TIMEOUT,
516 )
517 531
518 if is_initiator: 532 if is_initiator:
519 offer = await self._peer_connection.createOffer() 533 offer = await self._peer_connection.createOffer()
520 self._set_media_types(offer) 534 self._set_media_types(offer)
521 await self._peer_connection.setLocalDescription(offer) 535 await self._peer_connection.setLocalDescription(offer)
524 self._set_media_types(answer) 538 self._set_media_types(answer)
525 await self._peer_connection.setLocalDescription(answer) 539 await self._peer_connection.setLocalDescription(answer)
526 540
527 if not is_initiator: 541 if not is_initiator:
528 log.debug(self._peer_connection.localDescription.sdp) 542 log.debug(self._peer_connection.localDescription.sdp)
529 await self.candidates_gathered
530 log.debug(self._peer_connection.localDescription.sdp) 543 log.debug(self._peer_connection.localDescription.sdp)
531 timer.clear_timeout(gather_timeout) 544 ufrag, pwd = self.extract_ufrag_pwd(self._peer_connection.localDescription.sdp)
532 ufrag, pwd = self.extract_pwd_ufrag(self._peer_connection.localDescription.sdp)
533 return { 545 return {
534 "ufrag": ufrag, 546 "ufrag": ufrag,
535 "pwd": pwd, 547 "pwd": pwd,
536 "candidates": self.media_candidates, 548 "candidates": self.media_candidates,
537 } 549 }
542 @param session_id: Session identifier 554 @param session_id: Session identifier
543 @param sdp: Session Description Protocol data 555 @param sdp: Session Description Protocol data
544 @param profile: Profile associated 556 @param profile: Profile associated
545 """ 557 """
546 await self._peer_connection.setRemoteDescription({"type": "answer", "sdp": sdp}) 558 await self._peer_connection.setRemoteDescription({"type": "answer", "sdp": sdp})
547 await self.on_ice_candidates_new(self.candidates_buffer) 559 await self.on_ice_candidates_new(self.remote_candidates_buffer)
548 self.candidates_buffer.clear() 560 self.remote_candidates_buffer.clear()
549 561
550 def _on_ice_candidates_new(self, sid: str, candidates_s: str, profile: str) -> None: 562 def _on_ice_candidates_new(self, sid: str, candidates_s: str, profile: str) -> None:
551 """Called when new ICE candidates are received 563 """Called when new ICE candidates are received
552 564
553 @param sid: Session identifier 565 @param sid: Session identifier
572 """Called when new ICE canidates are received from peer 584 """Called when new ICE canidates are received from peer
573 585
574 @param candidates: Dictionary containing new ICE candidates 586 @param candidates: Dictionary containing new ICE candidates
575 """ 587 """
576 log.debug(f"new peer candidates received: {candidates}") 588 log.debug(f"new peer candidates received: {candidates}")
589 # FIXME: workaround for https://github.com/brython-dev/brython/issues/2227, the
590 # following test raise a JS exception
591 try:
592 remoteDescription_is_none = self._peer_connection.remoteDescription is None
593 except Exception as e:
594 log.debug("Workaround for Brython bug activated.")
595 remoteDescription_is_none = True
596
577 if ( 597 if (
578 self._peer_connection is None 598 self._peer_connection is None
579 or self._peer_connection.remoteDescription is None 599 # or self._peer_connection.remoteDescription is None
600 or remoteDescription_is_none
580 ): 601 ):
581 for media_type in ("audio", "video"): 602 for media_type in ("audio", "video"):
582 media_candidates = candidates.get(media_type) 603 media_candidates = candidates.get(media_type)
583 if media_candidates: 604 if media_candidates:
584 buffer = self.candidates_buffer[media_type] 605 buffer = self.remote_candidates_buffer[media_type]
585 buffer["candidates"].extend(media_candidates["candidates"]) 606 buffer["candidates"].extend(media_candidates["candidates"])
586 return 607 return
587 for media_type, ice_data in candidates.items(): 608 for media_type, ice_data in candidates.items():
588 for candidate in ice_data["candidates"]: 609 for candidate in ice_data["candidates"]:
589 candidate_sdp = self.build_ice_candidate(candidate) 610 candidate_sdp = self.build_ice_candidate(candidate)
623 await self._create_peer_connection() 644 await self._create_peer_connection()
624 645
625 await self._peer_connection.setRemoteDescription( 646 await self._peer_connection.setRemoteDescription(
626 {"type": "offer", "sdp": offer_sdp} 647 {"type": "offer", "sdp": offer_sdp}
627 ) 648 )
628 await self.on_ice_candidates_new(self.candidates_buffer) 649 await self.on_ice_candidates_new(self.remote_candidates_buffer)
629 self.candidates_buffer.clear() 650 self.remote_candidates_buffer.clear()
630 await self._get_user_media() 651 await self._get_user_media()
631 652
632 # Gather local ICE candidates 653 # Gather local ICE candidates
633 local_ice_data = await self._gather_ice_candidates(False) 654 local_ice_data = await self._gather_ice_candidates(False)
634 self.local_candidates = local_ice_data["candidates"] 655 self.local_candidates = local_ice_data["candidates"]
649 670
650 call_data = {"sdp": self._peer_connection.localDescription.sdp} 671 call_data = {"sdp": self._peer_connection.localDescription.sdp}
651 log.info(f"calling {callee_jid!r}") 672 log.info(f"calling {callee_jid!r}")
652 self.sid = await bridge.call_start(str(callee_jid), json.dumps(call_data)) 673 self.sid = await bridge.call_start(str(callee_jid), json.dumps(call_data))
653 log.debug(f"Call SID: {self.sid}") 674 log.debug(f"Call SID: {self.sid}")
675
676 if self.local_candidates_buffer:
677 log.debug(
678 f"sending buffered local ICE candidates: {self.local_candidates_buffer}"
679 )
680 assert self.pwd is not None
681 ice_data = {}
682 for media_type, candidates in self.local_candidates_buffer.items():
683 ice_data[media_type] = {
684 "ufrag": self.ufrag,
685 "pwd": self.pwd,
686 "candidates": candidates
687 }
688 aio.run(
689 bridge.ice_candidates_add(
690 self.sid,
691 json.dumps(
692 ice_data
693 ),
694 )
695 )
696 self.local_candidates_buffer.clear()
654 697
655 async def end_call(self) -> None: 698 async def end_call(self) -> None:
656 """Stop streaming and clean instance""" 699 """Stop streaming and clean instance"""
657 if self._peer_connection is None: 700 if self._peer_connection is None:
658 log.debug("There is currently no call to end.") 701 log.debug("There is currently no call to end.")
692 self.reset_instance() 735 self.reset_instance()
693 736
694 def toggle_media_mute(self, media_type: str) -> bool: 737 def toggle_media_mute(self, media_type: str) -> bool:
695 """Toggle mute/unmute for media tracks. 738 """Toggle mute/unmute for media tracks.
696 739
697 @param media_type: 'audio' or 'video'. Determines which media tracks 740 @param media_type: "audio" or "video". Determines which media tracks
698 to process. 741 to process.
699 """ 742 """
700 assert media_type in ("audio", "video"), "Invalid media type" 743 assert media_type in ("audio", "video"), "Invalid media type"
701 744
702 local_video = self.local_video_elt 745 local_video = self.local_video_elt
703 is_muted_attr = f"is_{media_type}_muted" 746 is_muted_attr = f"is_{media_type}_muted"
704 747
705 if local_video.srcObject: 748 if local_video.srcObject:
706 log.debug(f"{local_video.srcObject=}")
707 track_getter = getattr( 749 track_getter = getattr(
708 local_video.srcObject, f"get{media_type.capitalize()}Tracks" 750 local_video.srcObject, f"get{media_type.capitalize()}Tracks"
709 ) 751 )
710 log.debug("track go")
711 for track in track_getter(): 752 for track in track_getter():
712 log.debug(f"{track=}")
713 track.enabled = not track.enabled 753 track.enabled = not track.enabled
714 setattr(self, is_muted_attr, not track.enabled) 754 setattr(self, is_muted_attr, not track.enabled)
715 755
716 media_name = self.media_types_inv.get(media_type) 756 media_name = self.media_types_inv.get(media_type)
717 if media_name is not None: 757 if media_name is not None: