livekit-plugins-spatialreal 1.8.2__tar.gz → 1.8.4__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.5
2
2
  Name: livekit-plugins-spatialreal
3
- Version: 1.8.2
3
+ Version: 1.8.4
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/
@@ -18,8 +18,8 @@ Classifier: Topic :: Multimedia :: Sound/Audio
18
18
  Classifier: Topic :: Multimedia :: Video
19
19
  Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
20
  Requires-Python: >=3.10.0
21
- Requires-Dist: avatarkit>=0.1.7
22
21
  Requires-Dist: livekit-agents>=1.6.6
22
+ Requires-Dist: spatialreal>=0.1.0
23
23
  Provides-Extra: dev
24
24
  Requires-Dist: pytest; extra == 'dev'
25
25
  Requires-Dist: pytest-asyncio; extra == 'dev'
@@ -41,7 +41,7 @@ Starting with 1.7.1, the plugin's major and minor version track the `livekit-age
41
41
  release it was validated against (e.g. plugin 1.8.x targets `livekit-agents` 1.8.x). The
42
42
  patch number is the plugin's own and may advance independently.
43
43
 
44
- The current release, 1.8.2, is validated against `livekit-agents` 1.8.2.
44
+ The current release, 1.8.4, is validated against `livekit-agents` 1.8.4.
45
45
 
46
46
  Or install from source:
47
47
 
@@ -64,6 +64,9 @@ SPATIALREAL_CONSOLE_ENDPOINT=
64
64
  SPATIALREAL_INGRESS_ENDPOINT=
65
65
  # Max seconds of per-response audio retained for false-interruption resume (default 180, min 10)
66
66
  SPATIALREAL_RESUME_BUFFER_MAX_SECONDS=
67
+ # TTL (seconds) of the LiveKit room-join token the egress worker uses; must outlive
68
+ # the whole session so mid-session reconnects don't hit an expired token (default 86400)
69
+ SPATIALREAL_LIVEKIT_TOKEN_TTL_SECONDS=
67
70
 
68
71
  # LiveKit credentials
69
72
  LIVEKIT_URL=
@@ -138,6 +141,7 @@ Main class for integrating SpatialReal avatars with LiveKit agents.
138
141
  | `avatar_participant_name` | `str` | LiveKit display name for avatar participant |
139
142
  | `idle_timeout_seconds` | `int` | LiveKit egress idle timeout in seconds (`0` uses server defaults) |
140
143
  | `sample_rate` | `int \| None` | Optional avatar audio sample rate override |
144
+ | `livekit_token_ttl_seconds` | `int` | TTL of the LiveKit room-join token used by the egress worker; must outlive the whole session (or `SPATIALREAL_LIVEKIT_TOKEN_TTL_SECONDS`; default 86400) |
141
145
 
142
146
  #### Methods
143
147
 
@@ -14,7 +14,7 @@ Starting with 1.7.1, the plugin's major and minor version track the `livekit-age
14
14
  release it was validated against (e.g. plugin 1.8.x targets `livekit-agents` 1.8.x). The
15
15
  patch number is the plugin's own and may advance independently.
16
16
 
17
- The current release, 1.8.2, is validated against `livekit-agents` 1.8.2.
17
+ The current release, 1.8.4, is validated against `livekit-agents` 1.8.4.
18
18
 
19
19
  Or install from source:
20
20
 
@@ -37,6 +37,9 @@ SPATIALREAL_CONSOLE_ENDPOINT=
37
37
  SPATIALREAL_INGRESS_ENDPOINT=
38
38
  # Max seconds of per-response audio retained for false-interruption resume (default 180, min 10)
39
39
  SPATIALREAL_RESUME_BUFFER_MAX_SECONDS=
40
+ # TTL (seconds) of the LiveKit room-join token the egress worker uses; must outlive
41
+ # the whole session so mid-session reconnects don't hit an expired token (default 86400)
42
+ SPATIALREAL_LIVEKIT_TOKEN_TTL_SECONDS=
40
43
 
41
44
  # LiveKit credentials
42
45
  LIVEKIT_URL=
@@ -111,6 +114,7 @@ Main class for integrating SpatialReal avatars with LiveKit agents.
111
114
  | `avatar_participant_name` | `str` | LiveKit display name for avatar participant |
112
115
  | `idle_timeout_seconds` | `int` | LiveKit egress idle timeout in seconds (`0` uses server defaults) |
113
116
  | `sample_rate` | `int \| None` | Optional avatar audio sample rate override |
117
+ | `livekit_token_ttl_seconds` | `int` | TTL of the LiveKit room-join token used by the egress worker; must outlive the whole session (or `SPATIALREAL_LIVEKIT_TOKEN_TTL_SECONDS`; default 86400) |
114
118
 
115
119
  #### Methods
116
120
 
@@ -23,9 +23,6 @@ from dataclasses import dataclass, field
23
23
  from datetime import datetime, timedelta, timezone
24
24
  from typing import Any, Literal
25
25
 
26
- from avatarkit import AvatarSession as AvatarkitSession
27
- from avatarkit import LiveKitEgressConfig, new_avatar_session
28
- from avatarkit.proto.generated import message_pb2 as _message_pb2
29
26
  from livekit.agents import (
30
27
  NOT_GIVEN,
31
28
  AgentSession,
@@ -39,12 +36,19 @@ from livekit.agents.voice.io import AudioOutput
39
36
  from livekit.agents.voice.room_io import ATTRIBUTE_PUBLISH_ON_BEHALF
40
37
 
41
38
  from livekit import api, rtc
39
+ from spatialreal import AvatarSession as AvatarkitSession
40
+ from spatialreal import (
41
+ InterruptReason,
42
+ LiveKitEgressConfig,
43
+ PlaybackSignal,
44
+ PlaybackState,
45
+ PlaybackStateEvent,
46
+ new_avatar_session,
47
+ )
42
48
 
43
49
  from .log import logger
44
50
  from .resumable_queue_io import ResumableQueueAudioOutput
45
51
 
46
- message_pb2: Any = _message_pb2
47
-
48
52
  __all__ = [
49
53
  "AvatarPlaybackStartedEvent",
50
54
  "AvatarProviderConnectionEvent",
@@ -69,13 +73,25 @@ AVATAR_AUDIO_ACTIVITY_THRESHOLD = 100
69
73
  COMPLETED_REQ_ID_HISTORY = 32
70
74
  DEFAULT_RESUME_BUFFER_MAX_SECONDS = 180.0
71
75
  DEFAULT_SESSION_TTL = timedelta(hours=1)
76
+ # TTL of the LiveKit room-join token the egress worker uses to (re)join the room.
77
+ # This is NOT the SpatialReal session lifetime — the egress worker keeps re-joining
78
+ # the SFU across the whole session, including after a provider-WS reconnect, so the
79
+ # token must outlive any realistic conversation. When it expires mid-session, egress
80
+ # rejoins with a stale token and the SFU returns 401, which the plugin surfaces as an
81
+ # unrecoverable provider failure and the avatar drops out. Default 24h; override with
82
+ # SPATIALREAL_LIVEKIT_TOKEN_TTL_SECONDS or the livekit_token_ttl constructor arg.
83
+ DEFAULT_LIVEKIT_TOKEN_TTL = timedelta(hours=24)
72
84
  LIVEKIT_AVATAR_PUBLISH_SOURCES = ["camera", "microphone"]
73
85
  PROVIDER_RECONNECT_DELAYS_SECONDS = (0.0, 0.5, 1.0)
74
86
  PROVIDER_CONNECT_TIMEOUT_SECONDS = 15.0
75
87
  PROVIDER_CLOSE_TIMEOUT_SECONDS = 5.0
76
88
 
77
- DEFAULT_CONSOLE_ENDPOINT = "https://console.us-west.spatialwalk.cloud/v1/console"
78
- DEFAULT_INGRESS_ENDPOINT = "wss://api.us-west.spatialwalk.cloud/v2/driveningress"
89
+ # SpatialReal's production deployment. Override per session with the constructor arguments, or
90
+ # globally with SPATIALREAL_CONSOLE_ENDPOINT / SPATIALREAL_INGRESS_ENDPOINT (the test deployment is
91
+ # https://api.spatialreal.dev and wss://test-driven.spatialreal.dev/v2/driveningress). The SDK
92
+ # appends the routes (/v1/auth/session-token, /websocket).
93
+ DEFAULT_CONSOLE_ENDPOINT = "https://api.spatialreal.cloud"
94
+ DEFAULT_INGRESS_ENDPOINT = "wss://driven.us-west.spatialreal.cloud/v2/driveningress"
79
95
 
80
96
 
81
97
  class SpatialRealException(Exception):
@@ -131,6 +147,8 @@ class _SegmentState:
131
147
  attempt_duration: float = 0.0
132
148
  input_finalized: bool = False
133
149
  provider_events_trusted: bool = True
150
+ # set while the server holds playback (native pause); time held is not playback
151
+ native_paused_at: float | None = None
134
152
 
135
153
 
136
154
  class AvatarSession(BaseAvatarSession):
@@ -153,6 +171,7 @@ class AvatarSession(BaseAvatarSession):
153
171
  avatar_participant_name: NotGivenOr[str] = NOT_GIVEN,
154
172
  idle_timeout_seconds: int = 0,
155
173
  sample_rate: NotGivenOr[int] = NOT_GIVEN,
174
+ livekit_token_ttl_seconds: NotGivenOr[int] = NOT_GIVEN,
156
175
  ) -> None:
157
176
  super().__init__()
158
177
  resolved_api_key = api_key if utils.is_given(api_key) else os.getenv("SPATIALREAL_API_KEY")
@@ -181,6 +200,15 @@ class AvatarSession(BaseAvatarSession):
181
200
  if utils.is_given(sample_rate) and sample_rate <= 0:
182
201
  raise SpatialRealException("sample_rate must be greater than 0")
183
202
 
203
+ resolved_livekit_token_ttl = (
204
+ int(livekit_token_ttl_seconds)
205
+ if utils.is_given(livekit_token_ttl_seconds)
206
+ else self._int_env("SPATIALREAL_LIVEKIT_TOKEN_TTL_SECONDS", int(DEFAULT_LIVEKIT_TOKEN_TTL.total_seconds()))
207
+ )
208
+ if resolved_livekit_token_ttl <= 0:
209
+ raise SpatialRealException("livekit_token_ttl_seconds must be greater than 0")
210
+ self._livekit_token_ttl = timedelta(seconds=resolved_livekit_token_ttl)
211
+
184
212
  self._api_key = str(resolved_api_key)
185
213
  self._app_id = str(resolved_app_id)
186
214
  self._avatar_id = str(resolved_avatar_id)
@@ -232,6 +260,7 @@ class AvatarSession(BaseAvatarSession):
232
260
  self._close_lock = asyncio.Lock()
233
261
  self._background_tasks: set[asyncio.Task[Any]] = set()
234
262
  self._closing = False
263
+ self._server_playback_control = False
235
264
  self._pause_requested = False
236
265
  self._pause_requested_at: float | None = None
237
266
  self._discard_requested = False
@@ -309,7 +338,7 @@ class AvatarSession(BaseAvatarSession):
309
338
  .with_kind("agent")
310
339
  .with_identity(self._avatar_participant_identity)
311
340
  .with_name(self._avatar_participant_name)
312
- .with_ttl(DEFAULT_SESSION_TTL)
341
+ .with_ttl(self._livekit_token_ttl)
313
342
  .with_attributes(egress_attributes)
314
343
  .with_grants(
315
344
  api.VideoGrants(
@@ -487,6 +516,21 @@ class AvatarSession(BaseAvatarSession):
487
516
  return f"{type(root_error).__name__}: {message}"
488
517
  return type(root_error).__name__
489
518
 
519
+ @staticmethod
520
+ def _int_env(name: str, default: int) -> int:
521
+ raw = os.getenv(name)
522
+ if raw is None or not raw.strip():
523
+ return default
524
+ try:
525
+ value = int(raw)
526
+ except (TypeError, ValueError):
527
+ logger.warning("%s=%r is invalid; using %d", name, raw, default)
528
+ return default
529
+ if value <= 0:
530
+ logger.warning("%s=%d must be > 0; using %d", name, value, default)
531
+ return default
532
+ return value
533
+
490
534
  @staticmethod
491
535
  def _float_env(name: str, default: float, *, minimum: float) -> float:
492
536
  raw = os.getenv(name)
@@ -635,7 +679,8 @@ class AvatarSession(BaseAvatarSession):
635
679
  expire_at=datetime.now(timezone.utc) + DEFAULT_SESSION_TTL,
636
680
  livekit_egress=self._livekit_egress,
637
681
  sample_rate=self._resolved_sample_rate,
638
- transport_frames=self._on_transport_frame,
682
+ on_playback=self._on_playback_signal,
683
+ on_playback_state=self._on_playback_state,
639
684
  on_error=lambda error: self._on_provider_error(generation, error),
640
685
  on_close=lambda: self._on_provider_close(generation),
641
686
  )
@@ -658,6 +703,11 @@ class AvatarSession(BaseAvatarSession):
658
703
  if self._avatarkit_session is provider:
659
704
  self._avatarkit_session = None
660
705
  raise
706
+
707
+ # When the backend advertises server-side playback control we drive
708
+ # false-interruption pause/resume natively (no interrupt + re-send).
709
+ # Feature-detected per connection so a mixed fleet degrades gracefully.
710
+ self._server_playback_control = "playback_control" in getattr(provider, "capabilities", ())
661
711
  return provider
662
712
 
663
713
  async def _close_provider_session(
@@ -704,6 +754,8 @@ class AvatarSession(BaseAvatarSession):
704
754
  "SpatialReal provider websocket closed unexpectedly",
705
755
  extra={"generation": generation},
706
756
  )
757
+ # a server-side hold dies with its connection; fall back to duration completion
758
+ self._release_native_hold()
707
759
  self._schedule_provider_recovery(reason="provider_closed")
708
760
 
709
761
  def _schedule_provider_recovery(self, *, reason: str) -> asyncio.Task[bool] | None:
@@ -1152,6 +1204,10 @@ class AvatarSession(BaseAvatarSession):
1152
1204
  if segment is None:
1153
1205
  return
1154
1206
 
1207
+ # the server is holding playback; resume reschedules against the shifted start
1208
+ if segment.native_paused_at is not None:
1209
+ return
1210
+
1155
1211
  # if the avatar is still audibly speaking, allow a bounded overrun
1156
1212
  # before declaring the segment finished
1157
1213
  if segment.playback_started_at is not None and self._avatar_is_speaking:
@@ -1181,8 +1237,33 @@ class AvatarSession(BaseAvatarSession):
1181
1237
  extra={"request_id": req_id, "timeout": timeout},
1182
1238
  )
1183
1239
 
1184
- def _on_transport_frame(self, frame: bytes, is_last: bool) -> None:
1185
- req_id = self._extract_req_id_from_transport_frame(frame)
1240
+ def _on_playback_state(self, event: PlaybackStateEvent) -> None:
1241
+ # Structured server-side playback state (egress playback_control). The
1242
+ # PLAYING/PAUSED/ENDED signals are already handled via on_playback
1243
+ # (ServerResponseAnimation); the one thing that path can't express is a
1244
+ # server-forced interrupt of a held pause — surface that here so the
1245
+ # framework's speech handle isn't left hanging on a segment the server
1246
+ # has abandoned.
1247
+ if event.state != PlaybackState.INTERRUPTED:
1248
+ return
1249
+ if event.reason != InterruptReason.PAUSE_TIMEOUT:
1250
+ # explicit / preempted interrupts already flow through the framework's
1251
+ # own clear_buffer path; nothing extra to do.
1252
+ return
1253
+ logger.warning(
1254
+ "SpatialReal avatar pause timed out on the server; finalizing segment as interrupted",
1255
+ extra={"request_id": event.req_id, "played_ms": event.played_ms},
1256
+ )
1257
+ self._discard_requested = True
1258
+ self._pause_requested = False
1259
+ self._spawn_background_task(
1260
+ self._handle_interrupt(),
1261
+ name="spatialreal_avatar_pause_timeout",
1262
+ )
1263
+
1264
+ def _on_playback_signal(self, signal: PlaybackSignal) -> None:
1265
+ req_id = signal.req_id or None
1266
+ is_last = signal.end
1186
1267
  if req_id is not None and req_id in self._recently_completed_req_ids:
1187
1268
  logger.debug(
1188
1269
  "Ignoring duplicate provider event for completed request",
@@ -1430,20 +1511,6 @@ class AvatarSession(BaseAvatarSession):
1430
1511
  attempt=0,
1431
1512
  )
1432
1513
 
1433
- @staticmethod
1434
- def _extract_req_id_from_transport_frame(frame: bytes) -> str | None:
1435
- try:
1436
- envelope = message_pb2.Message()
1437
- envelope.ParseFromString(frame)
1438
- except Exception:
1439
- return None
1440
-
1441
- if envelope.type != message_pb2.MESSAGE_SERVER_RESPONSE_ANIMATION:
1442
- return None
1443
-
1444
- req_id = envelope.server_response_animation.req_id
1445
- return req_id or None
1446
-
1447
1514
  def _complete_segment(self, *, req_id: str, interrupted: bool, reason: str) -> bool:
1448
1515
  segment = self._segments.pop(req_id, None)
1449
1516
  if segment is None:
@@ -1572,9 +1639,18 @@ class AvatarSession(BaseAvatarSession):
1572
1639
  def _on_pause(self) -> None:
1573
1640
  if self._closing or self._drop_frames_until_segment_end:
1574
1641
  return
1642
+ self._discard_requested = False
1643
+ if self._server_playback_control:
1644
+ # Native path: the server holds its send cursor and keeps ingesting,
1645
+ # so we must NOT set _pause_requested (which diverts/retains frames)
1646
+ # — audio keeps flowing to the same request untouched.
1647
+ self._spawn_background_task(
1648
+ self._handle_pause_native(),
1649
+ name="spatialreal_avatar_pause_native",
1650
+ )
1651
+ return
1575
1652
  self._pause_requested = True
1576
1653
  self._pause_requested_at = time.time()
1577
- self._discard_requested = False
1578
1654
  self._spawn_background_task(
1579
1655
  self._handle_pause(),
1580
1656
  name="spatialreal_avatar_pause",
@@ -1587,11 +1663,68 @@ class AvatarSession(BaseAvatarSession):
1587
1663
  if self._discard_requested:
1588
1664
  logger.debug("Ignoring SpatialReal avatar resume after confirmed interruption")
1589
1665
  return
1666
+ if self._server_playback_control:
1667
+ self._spawn_background_task(
1668
+ self._handle_resume_native(),
1669
+ name="spatialreal_avatar_resume_native",
1670
+ )
1671
+ return
1590
1672
  self._spawn_background_task(
1591
1673
  self._handle_resume(),
1592
1674
  name="spatialreal_avatar_resume",
1593
1675
  )
1594
1676
 
1677
+ async def _handle_pause_native(self) -> None:
1678
+ """Server-side pause: hold the cursor, keep the segment and audio flow intact."""
1679
+ if not self._avatarkit_session or self._discard_requested:
1680
+ return
1681
+ async with self._provider_io_lock:
1682
+ if self._discard_requested or self._avatarkit_session is None:
1683
+ return
1684
+ try:
1685
+ req_id = await self._avatarkit_session.pause()
1686
+ logger.debug("SpatialReal avatar playback paused (server-side)", extra={"request_id": req_id})
1687
+ except Exception as e:
1688
+ logger.warning("Failed to pause SpatialReal avatar playback (server-side)", exc_info=e)
1689
+ return
1690
+ segment = self._segments.get(req_id) or self._segments.get(self._active_req_id or "")
1691
+ if segment is not None and segment.native_paused_at is None:
1692
+ segment.native_paused_at = time.time()
1693
+
1694
+ async def _handle_resume_native(self) -> None:
1695
+ """Server-side resume: continue the held cursor from where it stopped."""
1696
+ try:
1697
+ if not self._avatarkit_session or self._discard_requested:
1698
+ return
1699
+ async with self._provider_io_lock:
1700
+ if self._discard_requested or self._avatarkit_session is None:
1701
+ return
1702
+ try:
1703
+ req_id = await self._avatarkit_session.resume()
1704
+ logger.debug("SpatialReal avatar playback resumed (server-side)", extra={"request_id": req_id})
1705
+ except Exception as e:
1706
+ logger.warning("Failed to resume SpatialReal avatar playback (server-side)", exc_info=e)
1707
+ finally:
1708
+ # released on every path (failed resume, resume during a provider
1709
+ # reconnect) so a segment can't be stranded; a hold the server keeps
1710
+ # anyway ends in its pause_timeout interrupt
1711
+ self._release_native_hold()
1712
+
1713
+ def _release_native_hold(self) -> None:
1714
+ """Shift held segments' playback start by the time the server held them."""
1715
+ now = time.time()
1716
+ for segment in self._segments.values():
1717
+ if segment.native_paused_at is None:
1718
+ continue
1719
+ held = now - segment.native_paused_at
1720
+ segment.native_paused_at = None
1721
+ if segment.playback_started_at is not None:
1722
+ segment.playback_started_at += held
1723
+ elif segment.first_frame_at is not None:
1724
+ segment.first_frame_at += held
1725
+ if segment.req_id in self._pending_segment_ids:
1726
+ self._schedule_segment_completion(segment)
1727
+
1595
1728
  def _on_clear_buffer(self) -> None:
1596
1729
  if self._closing:
1597
1730
  return
@@ -0,0 +1 @@
1
+ __version__ = "1.8.4"
@@ -23,7 +23,7 @@ classifiers = [
23
23
  ]
24
24
  dependencies = [
25
25
  "livekit-agents>=1.6.6",
26
- "avatarkit>=0.1.7",
26
+ "spatialreal>=0.1.0",
27
27
  ]
28
28
 
29
29
  [project.optional-dependencies]
@@ -1 +0,0 @@
1
- __version__ = "1.8.2"