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.
- {livekit_plugins_spatialreal-1.8.2 → livekit_plugins_spatialreal-1.8.4}/PKG-INFO +7 -3
- {livekit_plugins_spatialreal-1.8.2 → livekit_plugins_spatialreal-1.8.4}/README.md +5 -1
- {livekit_plugins_spatialreal-1.8.2 → livekit_plugins_spatialreal-1.8.4}/livekit/plugins/spatialreal/avatar.py +159 -26
- livekit_plugins_spatialreal-1.8.4/livekit/plugins/spatialreal/version.py +1 -0
- {livekit_plugins_spatialreal-1.8.2 → livekit_plugins_spatialreal-1.8.4}/pyproject.toml +1 -1
- livekit_plugins_spatialreal-1.8.2/livekit/plugins/spatialreal/version.py +0 -1
- {livekit_plugins_spatialreal-1.8.2 → livekit_plugins_spatialreal-1.8.4}/.gitignore +0 -0
- {livekit_plugins_spatialreal-1.8.2 → livekit_plugins_spatialreal-1.8.4}/LICENSE +0 -0
- {livekit_plugins_spatialreal-1.8.2 → livekit_plugins_spatialreal-1.8.4}/livekit/plugins/spatialreal/__init__.py +0 -0
- {livekit_plugins_spatialreal-1.8.2 → livekit_plugins_spatialreal-1.8.4}/livekit/plugins/spatialreal/log.py +0 -0
- {livekit_plugins_spatialreal-1.8.2 → livekit_plugins_spatialreal-1.8.4}/livekit/plugins/spatialreal/py.typed +0 -0
- {livekit_plugins_spatialreal-1.8.2 → livekit_plugins_spatialreal-1.8.4}/livekit/plugins/spatialreal/resumable_queue_io.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: livekit-plugins-spatialreal
|
|
3
|
-
Version: 1.8.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
78
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
1185
|
-
|
|
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"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "1.8.2"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|