livekit-plugins-spatialreal 1.4.4__tar.gz → 1.4.5__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: livekit-plugins-spatialreal
3
- Version: 1.4.4
3
+ Version: 1.4.5
4
4
  Summary: Agent Framework plugin for SpatialReal Avatar
5
5
  Project-URL: Documentation, https://docs.spatialreal.com
6
6
  Project-URL: Website, https://www.spatialreal.ai/
@@ -22,7 +22,7 @@ Classifier: Topic :: Multimedia :: Sound/Audio
22
22
  Classifier: Topic :: Multimedia :: Video
23
23
  Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
24
24
  Requires-Python: >=3.10.0
25
- Requires-Dist: avatarkit>=0.1.4
25
+ Requires-Dist: avatarkit>=0.1.7
26
26
  Requires-Dist: livekit-agents>=1.2.9
27
27
  Description-Content-Type: text/markdown
28
28
 
@@ -3,7 +3,7 @@
3
3
  This plugin provides integration with SpatialReal's avatar service for
4
4
  lip-synced avatar rendering in LiveKit voice agents.
5
5
 
6
- See https://docs.spatialreal.com for more information.
6
+ See https://docs.spatialreal.ai for more information.
7
7
 
8
8
  Usage:
9
9
  from livekit.plugins.spatialreal import AvatarSession
@@ -23,8 +23,9 @@ from avatarkit import (
23
23
  new_avatar_session,
24
24
  )
25
25
  from avatarkit.proto.generated import message_pb2 as _message_pb2
26
- from livekit.agents import AgentSession, UserStateChangedEvent
26
+ from livekit.agents import AgentSession, UserStateChangedEvent, get_job_context
27
27
  from livekit.agents.voice.avatar import AudioSegmentEnd, QueueAudioOutput
28
+ from livekit.agents.voice.room_io import ATTRIBUTE_PUBLISH_ON_BEHALF
28
29
 
29
30
  from livekit import rtc
30
31
 
@@ -39,6 +40,8 @@ DEFAULT_SAMPLE_RATE = 24000
39
40
  MIN_COMPLETION_TIMEOUT_SECONDS = 3.0
40
41
  COMPLETION_TIMEOUT_BUFFER_SECONDS = 2.0
41
42
  ACTIVE_SEGMENT_IDLE_END_SECONDS = 1.0
43
+ DEFAULT_SESSION_TTL = timedelta(hours=1)
44
+ LIVEKIT_AVATAR_PUBLISH_SOURCES = ["camera", "microphone"]
42
45
 
43
46
  DEFAULT_CONSOLE_ENDPOINT = "https://console.us-west.spatialwalk.cloud/v1/console"
44
47
  DEFAULT_INGRESS_ENDPOINT = "wss://api.us-west.spatialwalk.cloud/v2/driveningress"
@@ -175,25 +178,36 @@ class AvatarSession:
175
178
  lk_api_key = livekit_api_key or os.getenv("LIVEKIT_API_KEY")
176
179
  lk_api_secret = livekit_api_secret or os.getenv("LIVEKIT_API_SECRET")
177
180
 
178
- if not lk_url or not lk_api_key or not lk_api_secret:
181
+ if not lk_url:
182
+ raise SpatialRealException("livekit_url must be provided or LIVEKIT_URL environment variable must be set")
183
+
184
+ if not lk_api_key or not lk_api_secret:
179
185
  raise SpatialRealException(
180
- "livekit_url, livekit_api_key, and livekit_api_secret must be provided "
181
- "or LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET environment variables must be set"
186
+ "livekit_api_key and livekit_api_secret must be provided "
187
+ "or LIVEKIT_API_KEY and LIVEKIT_API_SECRET environment variables must be set"
182
188
  )
183
189
 
184
190
  room_name = room.name
185
- agent_participant_identity = room.local_participant.identity
191
+ agent_participant_identity = self._resolve_local_participant_identity(room)
186
192
  logger.info(f"Initializing SpatialReal avatar session for room: {room_name}")
187
193
  logger.debug(f"Console endpoint: {self._console_endpoint_url}")
188
194
  logger.debug(f"Ingress endpoint: {self._ingress_endpoint_url}")
189
195
 
190
- egress_attributes = {"lk.publish_on_behalf": agent_participant_identity}
196
+ egress_attributes = {ATTRIBUTE_PUBLISH_ON_BEHALF: agent_participant_identity}
197
+ session_expire_at = datetime.now(timezone.utc) + DEFAULT_SESSION_TTL
198
+ resolved_lk_api_token = self._generate_livekit_api_token(
199
+ room_name=room_name,
200
+ livekit_api_key=lk_api_key,
201
+ livekit_api_secret=lk_api_secret,
202
+ publisher_identity=self._avatar_participant_identity,
203
+ publisher_attributes=egress_attributes,
204
+ ttl=DEFAULT_SESSION_TTL,
205
+ )
191
206
 
192
207
  # Create LiveKit egress configuration for the avatar to join the room
193
208
  livekit_egress_kwargs: dict[str, Any] = {
194
209
  "url": lk_url,
195
- "api_key": lk_api_key,
196
- "api_secret": lk_api_secret,
210
+ "api_token": resolved_lk_api_token,
197
211
  "room_name": room_name,
198
212
  "publisher_id": self._avatar_participant_identity,
199
213
  "extra_attributes": egress_attributes,
@@ -218,7 +232,7 @@ class AvatarSession:
218
232
  avatar_id=self._avatar_id,
219
233
  console_endpoint_url=self._console_endpoint_url,
220
234
  ingress_endpoint_url=self._ingress_endpoint_url,
221
- expire_at=datetime.now(timezone.utc) + timedelta(hours=1),
235
+ expire_at=session_expire_at,
222
236
  livekit_egress=livekit_egress,
223
237
  sample_rate=resolved_sample_rate,
224
238
  transport_frames=self._on_transport_frame,
@@ -283,11 +297,64 @@ class AvatarSession:
283
297
  reason = self._format_error_reason(error)
284
298
  return (
285
299
  "Failed to start SpatialReal avatar session. "
286
- "Check SpatialReal credentials, endpoint URLs, and outbound network access. "
300
+ "Check SpatialReal credentials, LiveKit room auth/token configuration, "
301
+ "endpoint URLs, and outbound network access. "
287
302
  f"room={room_name}, avatar_id={self._avatar_id}, ingress_endpoint_url={self._ingress_endpoint_url}, "
288
303
  f"sample_rate={sample_rate}. Reason: {reason}"
289
304
  )
290
305
 
306
+ @staticmethod
307
+ def _generate_livekit_api_token(
308
+ *,
309
+ room_name: str,
310
+ livekit_api_key: str,
311
+ livekit_api_secret: str,
312
+ publisher_identity: str,
313
+ publisher_attributes: dict[str, str],
314
+ ttl: timedelta,
315
+ ) -> str:
316
+ try:
317
+ from livekit import api as livekit_api
318
+ except ImportError as exc:
319
+ raise SpatialRealException(
320
+ "livekit-api must be installed to generate LiveKit access tokens for avatar egress"
321
+ ) from exc
322
+
323
+ try:
324
+ return (
325
+ livekit_api.AccessToken(livekit_api_key, livekit_api_secret)
326
+ .with_kind("agent")
327
+ .with_identity(publisher_identity)
328
+ .with_name(publisher_identity)
329
+ .with_ttl(ttl)
330
+ .with_attributes(publisher_attributes)
331
+ .with_grants(
332
+ livekit_api.VideoGrants(
333
+ room_join=True,
334
+ room=room_name,
335
+ can_subscribe=False,
336
+ can_publish_data=False,
337
+ can_publish_sources=LIVEKIT_AVATAR_PUBLISH_SOURCES,
338
+ )
339
+ )
340
+ .to_jwt()
341
+ )
342
+ except Exception as exc:
343
+ raise SpatialRealException(
344
+ "Failed to generate LiveKit access token for avatar worker. "
345
+ f"room={room_name}, publisher_identity={publisher_identity}. "
346
+ f"Reason: {AvatarSession._format_error_reason(exc)}"
347
+ ) from exc
348
+
349
+ @staticmethod
350
+ def _resolve_local_participant_identity(room: rtc.Room) -> str:
351
+ try:
352
+ return get_job_context().token_claims().identity
353
+ except RuntimeError as exc:
354
+ if not room.isconnected():
355
+ raise SpatialRealException("failed to get local participant identity") from exc
356
+ return room.local_participant.identity
357
+
291
358
  @staticmethod
292
359
  def _format_error_reason(error: BaseException) -> str:
293
360
  root_error = error
@@ -420,12 +487,18 @@ class AvatarSession:
420
487
  active_segment = self._segments.pop(active_req_id, None)
421
488
  segment = self._segments.get(req_id)
422
489
 
490
+ if active_segment is None and segment is None:
491
+ logger.debug(
492
+ "Avatar: Segment completed before finalization finished "
493
+ f"(request_id={active_req_id}, finalize_request_id={req_id}, source={source})"
494
+ )
495
+ return True
496
+
423
497
  if segment is None:
424
- if active_segment is not None:
425
- active_segment.req_id = req_id
426
- segment = active_segment
427
- else:
428
- segment = _SegmentState(req_id=req_id)
498
+ if active_segment is None:
499
+ return True
500
+ active_segment.req_id = req_id
501
+ segment = active_segment
429
502
  self._segments[req_id] = segment
430
503
  elif active_segment is not None and segment is not active_segment:
431
504
  segment.pushed_duration = max(segment.pushed_duration, active_segment.pushed_duration)
@@ -574,10 +647,18 @@ class AvatarSession:
574
647
  try:
575
648
  interrupted_id = await self._avatarkit_session.interrupt()
576
649
 
577
- if not self._complete_segment(req_id=interrupted_id, interrupted=True, reason="interrupt"):
578
- # Fallback: a race can leave the request id unmatched.
579
- if self._active_req_id is not None:
580
- self._complete_segment(req_id=self._active_req_id, interrupted=True, reason="interrupt_fallback")
650
+ async with self._segment_finalize_lock:
651
+ if not self._complete_segment(req_id=interrupted_id, interrupted=True, reason="interrupt"):
652
+ # Fallback: a race can leave the request id unmatched.
653
+ if self._active_req_id is not None:
654
+ self._complete_segment(
655
+ req_id=self._active_req_id,
656
+ interrupted=True,
657
+ reason="interrupt_fallback",
658
+ )
659
+ # Complete any remaining pending segments that were also interrupted
660
+ for req_id in list(self._segments.keys()):
661
+ self._complete_segment(req_id=req_id, interrupted=True, reason="interrupt_remaining")
581
662
 
582
663
  logger.debug(f"Avatar interrupted, request_id={interrupted_id}")
583
664
  except Exception as e:
@@ -0,0 +1 @@
1
+ __version__ = "1.4.5"
@@ -27,7 +27,7 @@ classifiers = [
27
27
  ]
28
28
  dependencies = [
29
29
  "livekit-agents>=1.2.9",
30
- "avatarkit>=0.1.4",
30
+ "avatarkit>=0.1.7",
31
31
  ]
32
32
 
33
33
  [project.urls]
@@ -1 +0,0 @@
1
- __version__ = "1.4.4"