Improving the reconnection logic to be able to recreate the peer connection in some cases.

This commit is contained in:
Filipi Fuchter
2025-03-13 17:07:32 -03:00
parent 30432639b4
commit 8f2dadf5a0
4 changed files with 96 additions and 34 deletions

View File

@@ -35,9 +35,14 @@ export class SmallWebRTCTransport {
private maxReconnectionAttempts = 3; private maxReconnectionAttempts = 3;
private isReconnecting = false; private isReconnecting = false;
private keepAliveInterval: number | null = null; private keepAliveInterval: number | null = null;
private audioDevice: string | undefined;
private videoDevice: string | undefined;
constructor(callbacks: SmallWebRTCTransportCallbacks) { constructor(callbacks: SmallWebRTCTransportCallbacks) {
this._callbacks = callbacks this._callbacks = callbacks
// for testing reconnections
// @ts-ignore
window.attemptReconnection = this.attemptReconnection.bind(this)
} }
private log(message: string): void { private log(message: string): void {
@@ -65,6 +70,9 @@ export class SmallWebRTCTransport {
pc.addEventListener('signalingstatechange', () => { pc.addEventListener('signalingstatechange', () => {
this.log(`signalingState: ${this.pc!.signalingState}`) this.log(`signalingState: ${this.pc!.signalingState}`)
if (this.pc!.signalingState == 'stable') {
this.handleReconnectionCompleted()
}
}); });
this.log(`signalingState: ${pc.signalingState}`) this.log(`signalingState: ${pc.signalingState}`)
@@ -94,20 +102,24 @@ export class SmallWebRTCTransport {
} }
} }
private handleReconnectionCompleted() {
this.reconnectionAttempts = 0;
this.isReconnecting = false;
}
private handleConnectionStateChange(): void { private handleConnectionStateChange(): void {
if (!this.pc) return; if (!this.pc) return;
this.log(`Connection State: ${this.pc.connectionState}`); this.log(`Connection State: ${this.pc.connectionState}`);
if (this.pc.connectionState === "connected") { if (this.pc.connectionState === "connected") {
this.reconnectionAttempts = 0; this.handleReconnectionCompleted()
this.isReconnecting = false;
this._callbacks.onConnected(); this._callbacks.onConnected();
} else if (this.pc.connectionState === "failed") { } else if (this.pc.connectionState === "failed") {
void this.attemptReconnection(true); void this.attemptReconnection(true);
} }
} }
private async attemptReconnection(iceRestart: boolean = false): Promise<void> { private async attemptReconnection(recreatePeerConnection: boolean = false): Promise<void> {
if (this.isReconnecting) { if (this.isReconnecting) {
this.log("Reconnection already in progress, skipping."); this.log("Reconnection already in progress, skipping.");
return; return;
@@ -120,17 +132,24 @@ export class SmallWebRTCTransport {
this.isReconnecting = true; this.isReconnecting = true;
this.reconnectionAttempts++; this.reconnectionAttempts++;
this.log(`Reconnection attempt ${this.reconnectionAttempts}...`); this.log(`Reconnection attempt ${this.reconnectionAttempts}...`);
await this.negotiate(iceRestart); // aiortc it is not working fine when just trying to restart the ice
// so in this case we are creating a new peer connection on both sides
if (recreatePeerConnection) {
//this.pc?.close()
await this.startNewPeerConnection(recreatePeerConnection)
} else {
await this.negotiate();
}
} }
private async negotiate(iceRestart: boolean = false): Promise<void> { private async negotiate(recreatePeerConnection: boolean = false): Promise<void> {
if (!this.pc) { if (!this.pc) {
return Promise.reject('Peer connection is not initialized'); return Promise.reject('Peer connection is not initialized');
} }
try { try {
// Create offer // Create offer
const offer = await this.pc.createOffer({iceRestart}); const offer = await this.pc.createOffer();
await this.pc.setLocalDescription(offer); await this.pc.setLocalDescription(offer);
// Wait for ICE gathering to complete // Wait for ICE gathering to complete
@@ -170,7 +189,8 @@ export class SmallWebRTCTransport {
body: JSON.stringify({ body: JSON.stringify({
sdp: offerSdp.sdp, sdp: offerSdp.sdp,
type: offerSdp.type, type: offerSdp.type,
pc_id: this.pc_id pc_id: this.pc_id,
restart_pc: recreatePeerConnection
}), }),
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -212,18 +232,24 @@ export class SmallWebRTCTransport {
} }
async start(audioDevice: string | undefined, audioCodec: string, videoCodec: string, videoDevice: string | undefined): Promise<void> { async start(audioDevice: string | undefined, audioCodec: string, videoCodec: string, videoDevice: string | undefined): Promise<void> {
this.audioDevice = audioDevice
this.videoDevice = videoDevice
this.audioCodec = audioCodec
this.videoCodec = videoCodec
await this.startNewPeerConnection()
}
private async startNewPeerConnection(recreatePeerConnection: boolean = false) {
this.pc = this.createPeerConnection(); this.pc = this.createPeerConnection();
this.addInitialTransceivers(); this.addInitialTransceivers();
this.dc = this.createDataChannel('chat', { ordered: true }); this.dc = this.createDataChannel('chat', { ordered: true });
await this.addUserMedias(audioDevice, videoDevice); await this.addUserMedias();
this.audioCodec = audioCodec await this.negotiate(recreatePeerConnection);
this.videoCodec = videoCodec
await this.negotiate();
} }
private async addUserMedias(audioDevice: string|undefined, videoDevice:string|undefined): Promise<void> { private async addUserMedias(): Promise<void> {
this.log("Will send the audio and video"); this.log("Will send the audio and video");
const constraints = this.createMediaConstraints(audioDevice, videoDevice); const constraints = this.createMediaConstraints();
if (constraints.audio || constraints.video) { if (constraints.audio || constraints.video) {
try { try {
@@ -314,16 +340,16 @@ export class SmallWebRTCTransport {
return dc; return dc;
} }
private createMediaConstraints(audioDevice: string|undefined, videoDevice:string|undefined): MediaStreamConstraints { private createMediaConstraints(): MediaStreamConstraints {
const constraints: MediaStreamConstraints = { audio: false, video: false }; const constraints: MediaStreamConstraints = { audio: false, video: false };
const audioConstraints: MediaTrackConstraints = {}; const audioConstraints: MediaTrackConstraints = {};
if (audioDevice) audioConstraints.deviceId = { exact: audioDevice }; if (this.audioDevice) audioConstraints.deviceId = { exact: this.audioDevice };
constraints.audio = Object.keys(audioConstraints).length ? audioConstraints : true; constraints.audio = Object.keys(audioConstraints).length ? audioConstraints : true;
const videoConstraints: MediaTrackConstraints = {}; const videoConstraints: MediaTrackConstraints = {};
if (videoDevice) videoConstraints.deviceId = { exact: videoDevice }; if (this.videoDevice) videoConstraints.deviceId = { exact: this.videoDevice };
constraints.video = Object.keys(videoConstraints).length ? videoConstraints : true; constraints.video = Object.keys(videoConstraints).length ? videoConstraints : true;

View File

@@ -29,7 +29,9 @@ async def offer(request: dict, background_tasks: BackgroundTasks):
if pc_id and pc_id in pcs_map: if pc_id and pc_id in pcs_map:
pipecat_connection = pcs_map[pc_id] pipecat_connection = pcs_map[pc_id]
logger.info(f"Reusing existing connection for pc_id: {pc_id}") logger.info(f"Reusing existing connection for pc_id: {pc_id}")
await pipecat_connection.renegotiate(sdp=request["sdp"], type=request["type"]) await pipecat_connection.renegotiate(
sdp=request["sdp"], type=request["type"], restart_pc=request.get("restart_pc", False)
)
else: else:
pipecat_connection = SmallWebRTCConnection() pipecat_connection = SmallWebRTCConnection()
await pipecat_connection.initialize(sdp=request["sdp"], type=request["type"]) await pipecat_connection.initialize(sdp=request["sdp"], type=request["type"])

View File

@@ -14,7 +14,7 @@ from typing import Any, Awaitable, Callable, Optional
import cv2 import cv2
import numpy as np import numpy as np
from aiortc import VideoStreamTrack from aiortc import VideoStreamTrack
from aiortc.mediastreams import AudioStreamTrack, VideoFrame from aiortc.mediastreams import AudioStreamTrack, MediaStreamError, VideoFrame
from av import AudioFrame, AudioResampler from av import AudioFrame, AudioResampler
from loguru import logger from loguru import logger
from pydantic import BaseModel from pydantic import BaseModel
@@ -191,6 +191,9 @@ class SmallWebRTCClient:
logger.warning("Timeout: No video frame received within the specified time.") logger.warning("Timeout: No video frame received within the specified time.")
self._webrtcConnection.ask_to_renegotiate() self._webrtcConnection.ask_to_renegotiate()
frame = None frame = None
except MediaStreamError:
logger.warning("Received an unexpected media stream error while reading the audio.")
frame = None
if frame is None or not isinstance(frame, VideoFrame): if frame is None or not isinstance(frame, VideoFrame):
# If no valid frame, sleep for a bit # If no valid frame, sleep for a bit
@@ -237,6 +240,9 @@ class SmallWebRTCClient:
if self._webrtcConnection.is_connected(): if self._webrtcConnection.is_connected():
logger.warning("Timeout: No audio frame received within the specified time.") logger.warning("Timeout: No audio frame received within the specified time.")
frame = None frame = None
except MediaStreamError:
logger.warning("Received an unexpected media stream error while reading the audio.")
frame = None
if frame is None or not isinstance(frame, AudioFrame): if frame is None or not isinstance(frame, AudioFrame):
# If we don't read any audio let's sleep for a little bit (i.e. busy wait). # If we don't read any audio let's sleep for a little bit (i.e. busy wait).
@@ -265,11 +271,11 @@ class SmallWebRTCClient:
yield audio_frame yield audio_frame
async def write_raw_audio_frames(self, data: bytes): async def write_raw_audio_frames(self, data: bytes):
if self._can_send(): if self._can_send() and self._audio_output_track:
await self._audio_output_track.add_audio_bytes(data) await self._audio_output_track.add_audio_bytes(data)
async def write_frame_to_camera(self, frame: OutputImageRawFrame): async def write_frame_to_camera(self, frame: OutputImageRawFrame):
if self._can_send(): if self._can_send() and self._video_output_track:
self._video_output_track.add_video_frame(frame) self._video_output_track.add_video_frame(frame)
async def setup(self, _params: TransportParams, frame): async def setup(self, _params: TransportParams, frame):
@@ -279,23 +285,13 @@ class SmallWebRTCClient:
self._params = _params self._params = _params
async def connect(self): async def connect(self):
if self._audio_output_track or self._video_output_track: if self._webrtcConnection.is_connected() or self._webrtcConnection.is_connecting():
# already initialized # already initialized
return return
logger.info(f"Connecting to Small WebRTC")
await self._webrtcConnection.connect() await self._webrtcConnection.connect()
logger.info(f"Connecting to Small WebRTC")
if self._params.audio_out_enabled:
self._audio_output_track = RawAudioTrack(sample_rate=self._out_sample_rate)
self._webrtcConnection.replace_audio_track(self._audio_output_track)
if self._params.camera_out_enabled:
self._video_output_track = RawVideoTrack(
width=self._params.camera_out_width, height=self._params.camera_out_height
)
self._webrtcConnection.replace_video_track(self._video_output_track)
async def disconnect(self): async def disconnect(self):
if self.is_connected and not self.is_closing: if self.is_connected and not self.is_closing:
@@ -311,12 +307,30 @@ class SmallWebRTCClient:
async def _handle_client_connected(self): async def _handle_client_connected(self):
self._audio_input_track = self._webrtcConnection.audio_input_track() self._audio_input_track = self._webrtcConnection.audio_input_track()
self._video_input_track = self._webrtcConnection.video_input_track() self._video_input_track = self._webrtcConnection.video_input_track()
if self._params.audio_out_enabled:
self._audio_output_track = RawAudioTrack(sample_rate=self._out_sample_rate)
self._webrtcConnection.replace_audio_track(self._audio_output_track)
if self._params.camera_out_enabled:
self._video_output_track = RawVideoTrack(
width=self._params.camera_out_width, height=self._params.camera_out_height
)
self._webrtcConnection.replace_video_track(self._video_output_track)
await self._callbacks.on_client_connected(self._webrtcConnection) await self._callbacks.on_client_connected(self._webrtcConnection)
async def _handle_client_disconnected(self): async def _handle_client_disconnected(self):
self._audio_input_track = None
self._video_input_track = None
self._audio_output_track = None
self._video_output_track = None
await self._callbacks.on_client_disconnected(self._webrtcConnection) await self._callbacks.on_client_disconnected(self._webrtcConnection)
async def _handle_client_closed(self): async def _handle_client_closed(self):
self._audio_input_track = None
self._video_input_track = None
self._audio_output_track = None
self._video_output_track = None
await self._callbacks.on_client_closed(self._webrtcConnection) await self._callbacks.on_client_closed(self._webrtcConnection)
async def _handle_app_message(self, message: Any): async def _handle_app_message(self, message: Any):

View File

@@ -1,7 +1,7 @@
import asyncio import asyncio
import json import json
import uuid
import time import time
import uuid
from enum import Enum from enum import Enum
from typing import Any, Optional from typing import Any, Optional
@@ -20,6 +20,11 @@ class SignallingMessage(Enum):
class SmallWebRTCConnection(EventEmitter): class SmallWebRTCConnection(EventEmitter):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._is_connecting = False
self._initialize()
def _initialize(self):
logger.info("Initializing new peer connection")
self.answer: Optional[RTCSessionDescription] = None self.answer: Optional[RTCSessionDescription] = None
self.pc = RTCPeerConnection() self.pc = RTCPeerConnection()
self.pc_id = "PeerConnection(%s)" % uuid.uuid4() self.pc_id = "PeerConnection(%s)" % uuid.uuid4()
@@ -87,10 +92,21 @@ class SmallWebRTCConnection(EventEmitter):
await self._create_answer(sdp, type) await self._create_answer(sdp, type)
async def connect(self): async def connect(self):
self._is_connecting = True
await self.pc.setLocalDescription(self.answer) await self.pc.setLocalDescription(self.answer)
async def renegotiate(self, sdp: str, type: str): async def renegotiate(self, sdp: str, type: str, restart_pc: bool = False):
logger.info(f"Renegotiating {self.pc_id}") logger.info(f"Renegotiating {self.pc_id}")
if restart_pc:
await self.emit("disconnected", self)
logger.info("Closing old peer connection")
# removing the listeners to prevent the bot from closing
self.pc.remove_all_listeners()
await self.close()
# we are initializing a new peer connection in this case.
self._initialize()
await self._create_answer(sdp, type) await self._create_answer(sdp, type)
await self.pc.setLocalDescription(self.answer) await self.pc.setLocalDescription(self.answer)
@@ -136,6 +152,7 @@ class SmallWebRTCConnection(EventEmitter):
async def close(self): async def close(self):
if self.pc: if self.pc:
await self.pc.close() await self.pc.close()
self._is_connecting = False
def get_answer(self): def get_answer(self):
if not self.answer: if not self.answer:
@@ -166,6 +183,9 @@ class SmallWebRTCConnection(EventEmitter):
# Checks if the last received ping was within the last 3 seconds. # Checks if the last received ping was within the last 3 seconds.
return (time.time() - self._last_received_time) < 3 return (time.time() - self._last_received_time) < 3
def is_connecting(self):
return self._is_connecting
def audio_input_track(self): def audio_input_track(self):
# Transceivers always appear in creation-order for both peers # Transceivers always appear in creation-order for both peers
# For now we are only considering that we are going to have 02 transceivers, # For now we are only considering that we are going to have 02 transceivers,