Skip to content

Commit d372a3f

Browse files
committed
chore: fix merge conflict issues
1 parent 0ae6fa2 commit d372a3f

5 files changed

Lines changed: 123 additions & 59 deletions

File tree

examples/record_audio/main.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -91,18 +91,23 @@ async def record(connection, recording_type, output_dir, user_id_filter=None):
9191
print(f" • Output Directory: {final_status['output_directory']}")
9292

9393

94-
async def record_composite(call, recorder_connection, bot_connections, players):
94+
async def record_composite(recorder_connection, bot_connections):
9595
"""Record composite audio from all participants."""
96-
await record(recorder_connection, RecordingType.COMPOSITE, "recordings/composite")
96+
await record(
97+
recorder_connection,
98+
RecordingType.COMPOSITE,
99+
"recordings/composite",
100+
user_id_filter=bot_connections,
101+
)
97102

98103

99-
async def record_tracks(call, recorder_connection, bot_connections, players):
104+
async def record_tracks(recorder_connection, bot_connections):
100105
"""Record individual tracks from each participant."""
101106
await record(
102107
recorder_connection,
103108
RecordingType.TRACK,
104109
"recordings/tracks",
105-
user_id_filter=bot_connections[0].user_id, # Record only the first bot's audio
110+
user_id_filter=bot_connections,
106111
)
107112

108113

@@ -189,17 +194,13 @@ async def main():
189194
# Run the selected recording type
190195
if args.type == "composite":
191196
await record_composite(
192-
call,
193197
recorder_connection,
194-
[bot1_connection, bot2_connection, bot3_connection],
195-
players,
198+
[bot1_connection.user_id],
196199
)
197200
else: # track recording
198201
await record_tracks(
199-
call,
200202
recorder_connection,
201-
[bot1_connection, bot2_connection, bot3_connection],
202-
players,
203+
[bot1_connection.user_id],
203204
)
204205

205206
for connection in [bot1_connection, bot2_connection, bot3_connection]:

getstream/video/rtc/connection_manager.py

Lines changed: 58 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
from twirp.context import Context
1010

1111
from getstream.utils import StreamAsyncIOEventEmitter
12+
from getstream.video.rtc.coordinator.ws import StreamAPIWS
13+
from getstream.video.rtc.pb.stream.video.sfu.event import events_pb2
1214
from getstream.video.rtc.pb.stream.video.sfu.models import models_pb2
1315
from getstream.video.rtc.pb.stream.video.sfu.signal_rpc import signal_pb2
14-
from getstream.video.rtc.twirp_client_wrapper import SignalClient
16+
from getstream.video.rtc.twirp_client_wrapper import SfuRpcError, SignalClient
1517

1618
from getstream.video.call import Call
1719
from getstream.video.rtc.connection_utils import (
@@ -21,6 +23,10 @@
2123
connect_websocket,
2224
join_call,
2325
)
26+
from getstream.video.rtc.track_util import (
27+
fix_sdp_msid_semantic,
28+
parse_track_stream_mapping,
29+
)
2430
from getstream.video.rtc.network_monitor import NetworkMonitor
2531
from getstream.video.rtc.recording import RecordingManager
2632
from getstream.video.rtc.participants import ParticipantsState
@@ -72,6 +78,7 @@ def __init__(
7278
self._recording_manager: RecordingManager = RecordingManager()
7379
self._network_monitor: NetworkMonitor = NetworkMonitor(self)
7480
self._reconnector: ReconnectionManager = ReconnectionManager(self)
81+
logger.info(f"VIVEK subscription_config: {subscription_config}")
7582
self._subscription_manager: SubscriptionManager = SubscriptionManager(
7683
self, subscription_config
7784
)
@@ -124,48 +131,57 @@ async def _on_ice_trickle(self, event):
124131
except Exception as e:
125132
logger.debug(f"Error handling ICE trickle: {e}")
126133

127-
async def _on_subscriber_offer(self, event):
128-
"""Handle subscriber offer from SFU."""
129-
logger.info(f"Received subscriber offer: ice_restart={event.ice_restart}")
130-
131-
try:
132-
# Ensure we have a subscriber peer connection
133-
if not self.subscriber_pc:
134-
await self._peer_manager.setup_subscriber()
135-
136-
# Parse SDP to extract track-to-stream mapping
137-
self._extract_track_stream_mapping(event.sdp)
134+
async def _on_subscriber_offer(self, event: events_pb2.SubscriberOffer):
135+
logger.info("Subscriber offer received")
138136

139-
# Handle ICE restart if needed
140-
if event.ice_restart:
141-
logger.info("Restarting ICE for subscriber")
142-
await self.subscriber_pc.restartIce()
137+
await self.subscriber_negotiation_lock.acquire()
143138

144-
# Set remote description with the SFU's offer
139+
try:
140+
# Fix any invalid msid-semantic format in the SDP
141+
fixed_sdp = fix_sdp_msid_semantic(event.sdp)
142+
# Parse SDP to create track_id to stream_id mapping
143+
self.participants_state.set_track_stream_mapping(
144+
parse_track_stream_mapping(fixed_sdp)
145+
)
146+
# The SDP offer from the SFU might already contain candidates (trickled)
147+
# or have a different structure. We set it as the remote description.
148+
# The aiortc library handles merging and interpretation.
145149
remote_description = aiortc.RTCSessionDescription(
146-
type="offer", sdp=event.sdp
150+
type="offer", sdp=fixed_sdp
147151
)
152+
logger.debug(f"""Setting remote description with SDP:
153+
{remote_description.sdp}""")
148154
await self.subscriber_pc.setRemoteDescription(remote_description)
149155

150-
# Create and set local answer
156+
# Create the answer based on the remote offer (which includes our candidates)
151157
answer = await self.subscriber_pc.createAnswer()
158+
# Set the local description. aiortc will manage the SDP content.
152159
await self.subscriber_pc.setLocalDescription(answer)
153160

154-
# Send answer back to SFU
155-
response = await self.twirp_signaling_client.SendAnswer(
156-
ctx=self.twirp_context,
157-
request=signal_pb2.SendAnswerRequest(
158-
session_id=self.session_id,
159-
peer_type=models_pb2.PEER_TYPE_SUBSCRIBER,
160-
sdp=self.subscriber_pc.localDescription.sdp,
161-
),
162-
server_path_prefix="",
161+
logger.info(
162+
f"""Sending answer with local description:
163+
{self.subscriber_pc.localDescription.sdp}"""
163164
)
164-
logger.info(f"Sent subscriber answer: {response}")
165165

166-
except Exception as e:
167-
logger.error(f"Error handling subscriber offer: {e}")
168-
raise
166+
try:
167+
await self.twirp_signaling_client.SendAnswer(
168+
ctx=self.twirp_context,
169+
request=signal_pb2.SendAnswerRequest(
170+
peer_type=models_pb2.PEER_TYPE_SUBSCRIBER,
171+
sdp=self.subscriber_pc.localDescription.sdp,
172+
session_id=self.session_id,
173+
),
174+
server_path_prefix="", # Note: Our wrapper doesn't need this, underlying client handles prefix
175+
)
176+
logger.info("Subscriber answer sent successfully.")
177+
except SfuRpcError as e:
178+
logger.error(f"Failed to send subscriber answer: {e}")
179+
# Decide how to handle: maybe close connection, notify user, etc.
180+
# For now, just log the error.
181+
except Exception as e:
182+
logger.error(f"Unexpected error sending subscriber answer: {e}")
183+
finally:
184+
self.subscriber_negotiation_lock.release()
169185

170186
def _extract_track_stream_mapping(self, sdp: str):
171187
"""Extract track-to-stream mapping from SDP."""
@@ -242,6 +258,8 @@ async def _connect_internal(
242258
# Use provided session_id or current one
243259
current_session_id = session_id or self.session_id
244260

261+
await self._peer_manager.setup_subscriber()
262+
245263
# Step 3: Connect to WebSocket
246264
try:
247265
self._ws_client, sfu_event = await connect_websocket(
@@ -290,20 +308,14 @@ async def _connect_internal(
290308
self.twirp_context = Context(headers={"authorization": token})
291309

292310
# Step 5: Create coordinator websocket (temporarily disabled to test)
293-
# user_token = self.call.client.stream.create_token(user_id=self.user_id)
294-
# self._coordinator_ws_client = StreamAPIWS(
295-
# api_key=self.call.client.stream.api_key,
296-
# token=user_token,
297-
# user_details={"id": self.user_id},
298-
# healthcheck_interval=15.0, # Send heartbeat every 15 seconds instead of 25
299-
# healthcheck_timeout=20.0, # Expect server messages within 20 seconds instead of 30
300-
# )
301-
# self._coordinator_ws_client.on_wildcard("*", _log_event)
302-
# await self._coordinator_ws_client.connect()
303-
self._coordinator_ws_client = None # Temporarily disable coordinator connection
304-
305-
# Step 6: Setup subscriber peer connection to receive incoming tracks
306-
await self._peer_manager.setup_subscriber()
311+
user_token = self.call.client.stream.create_token(user_id=self.user_id)
312+
self._coordinator_ws_client = StreamAPIWS(
313+
api_key=self.call.client.stream.api_key,
314+
token=user_token,
315+
user_details={"id": self.user_id},
316+
)
317+
self._coordinator_ws_client.on_wildcard("*", _log_event)
318+
await self._coordinator_ws_client.connect()
307319

308320
# Mark as connected
309321
self.running = True

getstream/video/rtc/coordinator/ws.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def __init__(
4141
user_details: Optional[dict] = None,
4242
*,
4343
uri: str = DEFAULT_WS_URI,
44-
healthcheck_interval: float = 25.0,
44+
healthcheck_interval: float = 15.0,
4545
healthcheck_timeout: float = 30.0,
4646
max_retries: int = 5,
4747
backoff_base: float = 1.0,

getstream/video/rtc/pc.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ def __init__(
118118

119119
@self.on("track")
120120
async def on_track(track: aiortc.mediastreams.MediaStreamTrack):
121-
logger.info(f"VIVEK Track received: {track.id} : {track.kind}")
121+
logger.info(f"Track received: {track.id} : {track.kind}")
122122

123123
# Try to get user from track ID first (original method)
124124
user = self.connection.participants_state.get_user_from_track_id(track.id)

getstream/video/rtc/recording.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -702,6 +702,10 @@ def __init__(self, config: Optional[RecordingConfig] = None):
702702

703703
self.config = config or RecordingConfig()
704704

705+
# Tracks that were received before recording started.
706+
# Mapping of track_id -> (user_id, track)
707+
self._pending_tracks: Dict[str, tuple[str, MediaStreamTrack]] = {}
708+
705709
# Recording state
706710
self._recording_types: Set[RecordingType] = set()
707711
self._target_user_ids: Optional[Set[str]] = None
@@ -752,6 +756,9 @@ async def start_recording(
752756
# Set up event forwarding for new recorders
753757
self._setup_recorder_events()
754758

759+
# Process any tracks that were received before recording was enabled.
760+
await self._process_pending_tracks()
761+
755762
self.emit('recording_started', {
756763
'recording_types': [rt.value for rt in recording_types],
757764
'user_ids': user_ids,
@@ -803,6 +810,12 @@ async def on_user_left(self, user_id: str):
803810
if self._composite_audio_recorder:
804811
self._composite_audio_recorder.remove_user_track(user_id)
805812

813+
# Remove from pending tracks
814+
for track_id in list(self._pending_tracks.keys()):
815+
pending_user, _ = self._pending_tracks[track_id]
816+
if pending_user == user_id:
817+
del self._pending_tracks[track_id]
818+
806819
# Stop track recording (both audio and video)
807820
await self._stop_user_recording(user_id)
808821
logger.info(f"Stopped recording for user {user_id} who left the call")
@@ -814,8 +827,13 @@ async def on_track_removed(self, user_id: str, track_type: str):
814827
await self._stop_user_recording_by_key(recorder_key, track_type)
815828
logger.info(f"Stopped {track_type} recording for user {user_id} whose {track_type} track was removed")
816829

830+
# Remove from pending
831+
for track_id in list(self._pending_tracks.keys()):
832+
pending_user, pending_track = self._pending_tracks[track_id]
833+
if pending_user == user_id and pending_track.kind == track_type:
834+
del self._pending_tracks[track_id]
835+
817836
async def on_track_received(self, track, user):
818-
logger.info(f"VIVEK on_track_received: {track.kind} track for user {user}")
819837
"""Handle new track received from track event."""
820838
if not track or track.kind not in ["audio", "video"]:
821839
return
@@ -838,6 +856,11 @@ async def on_track_received(self, track, user):
838856
if RecordingType.TRACK in self._recording_types:
839857
await self._start_user_track_recording(user_id, track)
840858

859+
# Cache track so that we can start recording later if recording is not
860+
# yet enabled.
861+
if RecordingType.TRACK not in self._recording_types:
862+
self._pending_tracks[track.id] = (user_id, track)
863+
841864
async def _start_user_track_recording(self, user_id: str, track: MediaStreamTrack):
842865
"""Start recording a user track using MediaRecorder."""
843866
track_type = TrackType.AUDIO if track.kind == "audio" else TrackType.VIDEO
@@ -1166,3 +1189,31 @@ async def cleanup(self):
11661189
self._composite_audio_recorder = None
11671190

11681191
logger.info("RecordingManager cleanup completed")
1192+
1193+
async def _process_pending_tracks(self):
1194+
"""Start recording for any tracks that were received before recording was enabled."""
1195+
if not self._pending_tracks:
1196+
return
1197+
1198+
for track_id, (user_id, track) in list(self._pending_tracks.items()):
1199+
try:
1200+
# Filter by requested user_ids if provided
1201+
if self._target_user_ids and user_id not in self._target_user_ids:
1202+
continue
1203+
1204+
# Start individual track recording if requested
1205+
if RecordingType.TRACK in self._recording_types:
1206+
await self._start_user_track_recording(user_id, track)
1207+
1208+
# Add to composite recorder if requested and audio track
1209+
if (
1210+
RecordingType.COMPOSITE in self._recording_types
1211+
and track.kind == "audio"
1212+
and self._composite_audio_recorder is not None
1213+
):
1214+
self._composite_audio_recorder.add_user_track(user_id, track)
1215+
1216+
# Remove from pending once handled
1217+
del self._pending_tracks[track_id]
1218+
except Exception as e:
1219+
logger.warning(f"Error processing pending track {track_id}: {e}")

0 commit comments

Comments
 (0)