livekit-plugins-spatialreal 1.4.3__tar.gz → 1.4.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.4
2
2
  Name: livekit-plugins-spatialreal
3
- Version: 1.4.3
3
+ Version: 1.4.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/
@@ -99,6 +99,16 @@ if __name__ == "__main__":
99
99
  cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
100
100
  ```
101
101
 
102
+ For production agents, catch `SpatialRealException` so you can decide whether to fail the job or continue without avatar output:
103
+
104
+ ```python
105
+ try:
106
+ await avatar.start(session, room=ctx.room)
107
+ except spatialreal.SpatialRealException as err:
108
+ logger.error("Avatar startup failed: %s", err)
109
+ raise
110
+ ```
111
+
102
112
  ## API Reference
103
113
 
104
114
  ### `AvatarSession`
@@ -116,10 +126,11 @@ Main class for integrating SpatialReal avatars with LiveKit agents.
116
126
  | `ingress_endpoint_url` | `str` | Custom ingress endpoint URL |
117
127
  | `avatar_participant_identity` | `str` | LiveKit identity for avatar participant |
118
128
  | `idle_timeout_seconds` | `int` | LiveKit egress idle timeout in seconds (`0` uses server defaults) |
129
+ | `sample_rate` | `int \| None` | Optional avatar audio sample rate override |
119
130
 
120
131
  #### Methods
121
132
 
122
- - `start(agent_session, room, *, livekit_url, livekit_api_key, livekit_api_secret)`: Start the avatar session and hook into the agent's audio output.
133
+ - `start(agent_session, room, *, livekit_url, livekit_api_key, livekit_api_secret)`: Start the avatar session and hook into the agent's audio output. Raises `SpatialRealException` with actionable context if startup fails.
123
134
  - `aclose()`: Clean up avatar session resources.
124
135
 
125
136
  When starting, the plugin automatically sets `lk.publish_on_behalf` to the
@@ -71,6 +71,16 @@ if __name__ == "__main__":
71
71
  cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
72
72
  ```
73
73
 
74
+ For production agents, catch `SpatialRealException` so you can decide whether to fail the job or continue without avatar output:
75
+
76
+ ```python
77
+ try:
78
+ await avatar.start(session, room=ctx.room)
79
+ except spatialreal.SpatialRealException as err:
80
+ logger.error("Avatar startup failed: %s", err)
81
+ raise
82
+ ```
83
+
74
84
  ## API Reference
75
85
 
76
86
  ### `AvatarSession`
@@ -88,10 +98,11 @@ Main class for integrating SpatialReal avatars with LiveKit agents.
88
98
  | `ingress_endpoint_url` | `str` | Custom ingress endpoint URL |
89
99
  | `avatar_participant_identity` | `str` | LiveKit identity for avatar participant |
90
100
  | `idle_timeout_seconds` | `int` | LiveKit egress idle timeout in seconds (`0` uses server defaults) |
101
+ | `sample_rate` | `int \| None` | Optional avatar audio sample rate override |
91
102
 
92
103
  #### Methods
93
104
 
94
- - `start(agent_session, room, *, livekit_url, livekit_api_key, livekit_api_secret)`: Start the avatar session and hook into the agent's audio output.
105
+ - `start(agent_session, room, *, livekit_url, livekit_api_key, livekit_api_secret)`: Start the avatar session and hook into the agent's audio output. Raises `SpatialRealException` with actionable context if startup fails.
95
106
  - `aclose()`: Clean up avatar session resources.
96
107
 
97
108
  When starting, the plugin automatically sets `lk.publish_on_behalf` to the
@@ -0,0 +1,626 @@
1
+ """
2
+ SpatialReal Avatar integration for LiveKit Agents.
3
+
4
+ This module provides AvatarSession which hooks into an AgentSession
5
+ to route TTS audio to the SpatialReal avatar service.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import os
12
+ import time
13
+ from collections import deque
14
+ from dataclasses import dataclass
15
+ from datetime import datetime, timedelta, timezone
16
+ from typing import Any
17
+
18
+ from avatarkit import (
19
+ AvatarSession as AvatarkitSession,
20
+ )
21
+ from avatarkit import (
22
+ LiveKitEgressConfig,
23
+ new_avatar_session,
24
+ )
25
+ from avatarkit.proto.generated import message_pb2 as _message_pb2
26
+ from livekit.agents import AgentSession, UserStateChangedEvent
27
+ from livekit.agents.voice.avatar import AudioSegmentEnd, QueueAudioOutput
28
+
29
+ from livekit import rtc
30
+
31
+ from .log import logger
32
+
33
+ message_pb2: Any = _message_pb2
34
+
35
+ __all__ = ["AvatarSession", "SpatialRealException"]
36
+
37
+ DEFAULT_AVATAR_PARTICIPANT_IDENTITY = "spatialreal-avatar"
38
+ DEFAULT_SAMPLE_RATE = 24000
39
+ MIN_COMPLETION_TIMEOUT_SECONDS = 3.0
40
+ COMPLETION_TIMEOUT_BUFFER_SECONDS = 2.0
41
+ ACTIVE_SEGMENT_IDLE_END_SECONDS = 1.0
42
+
43
+ DEFAULT_CONSOLE_ENDPOINT = "https://console.us-west.spatialwalk.cloud/v1/console"
44
+ DEFAULT_INGRESS_ENDPOINT = "wss://api.us-west.spatialwalk.cloud/v2/driveningress"
45
+
46
+
47
+ class SpatialRealException(Exception):
48
+ """Exception raised for SpatialReal-related errors."""
49
+
50
+ pass
51
+
52
+
53
+ @dataclass
54
+ class _SegmentState:
55
+ req_id: str
56
+ pushed_duration: float = 0.0
57
+ first_frame_at: float | None = None
58
+ completion_timeout_task: asyncio.Task[None] | None = None
59
+
60
+
61
+ class AvatarSession:
62
+ """
63
+ This connects to SpatialReal's avatar service and routes TTS audio
64
+ from the agent to the avatar for lip-synced rendering. The avatar
65
+ service joins the LiveKit room and publishes synchronized video + audio.
66
+
67
+ Args:
68
+ api_key: SpatialReal API key. Falls back to SPATIALREAL_API_KEY env var.
69
+ app_id: SpatialReal application ID. Falls back to SPATIALREAL_APP_ID env var.
70
+ avatar_id: Avatar ID to use. Falls back to SPATIALREAL_AVATAR_ID env var.
71
+ console_endpoint_url: Console endpoint URL. Falls back to
72
+ SPATIALREAL_CONSOLE_ENDPOINT env var or default.
73
+ ingress_endpoint_url: Ingress endpoint URL. Falls back to
74
+ SPATIALREAL_INGRESS_ENDPOINT env var or default.
75
+ avatar_participant_identity: LiveKit identity for the avatar participant.
76
+ idle_timeout_seconds: Idle timeout in seconds for the egress connection.
77
+ A value of 0 uses server defaults.
78
+ sample_rate: Optional audio sample rate override for avatar audio.
79
+ Falls back to agent_session.tts.sample_rate or a default value.
80
+
81
+ Usage:
82
+ avatar = AvatarSession()
83
+ await avatar.start(session, room=ctx.room)
84
+ """
85
+
86
+ def __init__(
87
+ self,
88
+ *,
89
+ api_key: str | None = None,
90
+ app_id: str | None = None,
91
+ avatar_id: str | None = None,
92
+ console_endpoint_url: str | None = None,
93
+ ingress_endpoint_url: str | None = None,
94
+ avatar_participant_identity: str | None = None,
95
+ idle_timeout_seconds: int = 0,
96
+ sample_rate: int | None = None,
97
+ ) -> None:
98
+ # Resolve API key
99
+ self._api_key = api_key or os.getenv("SPATIALREAL_API_KEY")
100
+ if not self._api_key:
101
+ raise SpatialRealException(
102
+ "api_key must be provided or SPATIALREAL_API_KEY environment variable must be set"
103
+ )
104
+
105
+ # Resolve app ID
106
+ self._app_id = app_id or os.getenv("SPATIALREAL_APP_ID")
107
+ if not self._app_id:
108
+ raise SpatialRealException("app_id must be provided or SPATIALREAL_APP_ID environment variable must be set")
109
+
110
+ # Resolve avatar ID
111
+ self._avatar_id = avatar_id or os.getenv("SPATIALREAL_AVATAR_ID")
112
+ if not self._avatar_id:
113
+ raise SpatialRealException(
114
+ "avatar_id must be provided or SPATIALREAL_AVATAR_ID environment variable must be set"
115
+ )
116
+
117
+ # Resolve endpoints
118
+ self._console_endpoint_url = (
119
+ console_endpoint_url or os.getenv("SPATIALREAL_CONSOLE_ENDPOINT") or DEFAULT_CONSOLE_ENDPOINT
120
+ )
121
+ self._ingress_endpoint_url = (
122
+ ingress_endpoint_url or os.getenv("SPATIALREAL_INGRESS_ENDPOINT") or DEFAULT_INGRESS_ENDPOINT
123
+ )
124
+
125
+ # Avatar participant configuration
126
+ self._avatar_participant_identity = avatar_participant_identity or DEFAULT_AVATAR_PARTICIPANT_IDENTITY
127
+
128
+ if idle_timeout_seconds < 0:
129
+ raise SpatialRealException("idle_timeout_seconds must be greater than or equal to 0")
130
+ self._idle_timeout_seconds = idle_timeout_seconds
131
+
132
+ if sample_rate is not None and sample_rate <= 0:
133
+ raise SpatialRealException("sample_rate must be greater than 0")
134
+ self._sample_rate = sample_rate
135
+
136
+ # Internal state
137
+ self._avatarkit_session: AvatarkitSession | None = None
138
+ self._agent_session: AgentSession | None = None
139
+ self._audio_buffer: QueueAudioOutput | None = None
140
+ self._original_audio_output: Any | None = None
141
+ self._audio_output_attached = False
142
+ self._main_task: asyncio.Task | None = None
143
+ self._initialized = False
144
+ self._segments: dict[str, _SegmentState] = {}
145
+ self._pending_segment_ids: deque[str] = deque()
146
+ self._active_req_id: str | None = None
147
+ self._active_segment_idle_end_task: asyncio.Task[None] | None = None
148
+ self._segment_finalize_lock = asyncio.Lock()
149
+
150
+ async def start(
151
+ self,
152
+ agent_session: AgentSession,
153
+ room: rtc.Room,
154
+ *,
155
+ livekit_url: str | None = None,
156
+ livekit_api_key: str | None = None,
157
+ livekit_api_secret: str | None = None,
158
+ ) -> None:
159
+ """
160
+ Start the avatar session and hook into the agent session.
161
+
162
+ Args:
163
+ agent_session: The AgentSession to hook into for TTS audio.
164
+ room: The LiveKit room for egress configuration.
165
+ livekit_url: LiveKit server URL. Falls back to LIVEKIT_URL env var.
166
+ livekit_api_key: LiveKit API key. Falls back to LIVEKIT_API_KEY env var.
167
+ livekit_api_secret: LiveKit API secret. Falls back to LIVEKIT_API_SECRET env var.
168
+ """
169
+ if self._initialized:
170
+ logger.warning("Avatar session already initialized")
171
+ return
172
+
173
+ # Resolve LiveKit credentials
174
+ lk_url = livekit_url or os.getenv("LIVEKIT_URL")
175
+ lk_api_key = livekit_api_key or os.getenv("LIVEKIT_API_KEY")
176
+ lk_api_secret = livekit_api_secret or os.getenv("LIVEKIT_API_SECRET")
177
+
178
+ if not lk_url or not lk_api_key or not lk_api_secret:
179
+ 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"
182
+ )
183
+
184
+ room_name = room.name
185
+ agent_participant_identity = room.local_participant.identity
186
+ logger.info(f"Initializing SpatialReal avatar session for room: {room_name}")
187
+ logger.debug(f"Console endpoint: {self._console_endpoint_url}")
188
+ logger.debug(f"Ingress endpoint: {self._ingress_endpoint_url}")
189
+
190
+ egress_attributes = {"lk.publish_on_behalf": agent_participant_identity}
191
+
192
+ # Create LiveKit egress configuration for the avatar to join the room
193
+ livekit_egress_kwargs: dict[str, Any] = {
194
+ "url": lk_url,
195
+ "api_key": lk_api_key,
196
+ "api_secret": lk_api_secret,
197
+ "room_name": room_name,
198
+ "publisher_id": self._avatar_participant_identity,
199
+ "extra_attributes": egress_attributes,
200
+ "idle_timeout": self._idle_timeout_seconds,
201
+ }
202
+ livekit_egress = LiveKitEgressConfig(**livekit_egress_kwargs)
203
+
204
+ resolved_sample_rate = self._sample_rate
205
+ if resolved_sample_rate is None:
206
+ resolved_sample_rate = agent_session.tts.sample_rate if agent_session.tts else DEFAULT_SAMPLE_RATE
207
+ if resolved_sample_rate <= 0:
208
+ raise SpatialRealException("sample_rate must be greater than 0")
209
+
210
+ self._agent_session = agent_session
211
+ self._original_audio_output = agent_session.output.audio
212
+
213
+ try:
214
+ # Create avatar session with LiveKit egress mode
215
+ self._avatarkit_session = new_avatar_session(
216
+ api_key=self._api_key,
217
+ app_id=self._app_id,
218
+ avatar_id=self._avatar_id,
219
+ console_endpoint_url=self._console_endpoint_url,
220
+ ingress_endpoint_url=self._ingress_endpoint_url,
221
+ expire_at=datetime.now(timezone.utc) + timedelta(hours=1),
222
+ livekit_egress=livekit_egress,
223
+ sample_rate=resolved_sample_rate,
224
+ transport_frames=self._on_transport_frame,
225
+ )
226
+
227
+ # Initialize and start the avatar session
228
+ await self._avatarkit_session.init()
229
+ await self._avatarkit_session.start()
230
+ logger.info("SpatialReal avatar session connected")
231
+
232
+ # Create audio buffer using livekit-agents' QueueAudioOutput
233
+ self._audio_buffer = QueueAudioOutput(sample_rate=resolved_sample_rate)
234
+
235
+ # Hook into agent session's audio output
236
+ agent_session.output.audio = self._audio_buffer
237
+ self._audio_output_attached = True
238
+
239
+ # Start the audio buffer
240
+ await self._audio_buffer.start()
241
+
242
+ # Register for clear_buffer events (interruptions)
243
+ self._audio_buffer.on("clear_buffer", self._on_clear_buffer) # type: ignore[arg-type]
244
+
245
+ # Register for user_state_changed events (interrupt on user speaking)
246
+ @agent_session.on("user_state_changed")
247
+ def on_user_state_changed(ev: UserStateChangedEvent) -> None:
248
+ if ev.new_state == "speaking":
249
+ asyncio.create_task(self._handle_interrupt())
250
+
251
+ # Start the main task that forwards audio to avatar
252
+ self._main_task = asyncio.create_task(self._run_main_task())
253
+
254
+ self._initialized = True
255
+ logger.info("Avatar audio output attached to agent session")
256
+
257
+ # Register cleanup on session close
258
+ @agent_session.on("close")
259
+ def on_session_close() -> None:
260
+ asyncio.create_task(self.aclose())
261
+
262
+ except asyncio.CancelledError:
263
+ await self.aclose()
264
+ raise
265
+ except Exception as e:
266
+ logger.debug("SpatialReal avatar session startup failed", exc_info=True)
267
+ await self.aclose()
268
+ raise SpatialRealException(
269
+ self._build_start_error_message(
270
+ error=e,
271
+ room_name=room_name,
272
+ sample_rate=resolved_sample_rate,
273
+ )
274
+ ) from None
275
+
276
+ def _build_start_error_message(
277
+ self,
278
+ *,
279
+ error: Exception,
280
+ room_name: str,
281
+ sample_rate: int,
282
+ ) -> str:
283
+ reason = self._format_error_reason(error)
284
+ return (
285
+ "Failed to start SpatialReal avatar session. "
286
+ "Check SpatialReal credentials, endpoint URLs, and outbound network access. "
287
+ f"room={room_name}, avatar_id={self._avatar_id}, ingress_endpoint_url={self._ingress_endpoint_url}, "
288
+ f"sample_rate={sample_rate}. Reason: {reason}"
289
+ )
290
+
291
+ @staticmethod
292
+ def _format_error_reason(error: BaseException) -> str:
293
+ root_error = error
294
+ seen_errors: set[int] = set()
295
+
296
+ while id(root_error) not in seen_errors:
297
+ seen_errors.add(id(root_error))
298
+ next_error = root_error.__cause__ or (None if root_error.__suppress_context__ else root_error.__context__)
299
+ if next_error is None:
300
+ break
301
+ root_error = next_error
302
+
303
+ message = str(root_error) or str(error)
304
+ if message:
305
+ return f"{type(root_error).__name__}: {message}"
306
+
307
+ return type(root_error).__name__
308
+
309
+ async def _run_main_task(self) -> None:
310
+ """Main task that forwards audio from the buffer to the avatar service."""
311
+ if not self._audio_buffer or not self._avatarkit_session:
312
+ return
313
+
314
+ try:
315
+ async for item in self._audio_buffer:
316
+ if isinstance(item, rtc.AudioFrame):
317
+ # Convert AudioFrame to bytes and send to avatar
318
+ audio_bytes = bytes(item.data)
319
+
320
+ previous_req_id = self._active_req_id
321
+
322
+ req_id = await self._avatarkit_session.send_audio(
323
+ audio=audio_bytes,
324
+ end=False,
325
+ )
326
+
327
+ if previous_req_id and previous_req_id != req_id:
328
+ logger.warning(
329
+ "Avatar: request ID changed while streaming audio "
330
+ f"(previous={previous_req_id}, current={req_id})"
331
+ )
332
+ previous_segment = self._segments.get(previous_req_id)
333
+ if previous_segment is not None:
334
+ self._mark_segment_waiting_for_completion(previous_segment)
335
+
336
+ segment = self._segments.get(req_id)
337
+ if segment is None:
338
+ segment = _SegmentState(req_id=req_id)
339
+ self._segments[req_id] = segment
340
+
341
+ if segment.first_frame_at is None:
342
+ segment.first_frame_at = time.time()
343
+ logger.debug(f"Avatar: First audio frame received (request_id={req_id})")
344
+
345
+ segment.pushed_duration += item.duration
346
+ self._active_req_id = req_id
347
+ self._schedule_active_segment_idle_end()
348
+
349
+ elif isinstance(item, AudioSegmentEnd):
350
+ # End of audio segment - signal completion to avatar
351
+ if not await self._finalize_active_segment(source="segment_end"):
352
+ logger.debug("Avatar: Segment end received without an active request")
353
+
354
+ except asyncio.CancelledError:
355
+ logger.debug("Avatar main task cancelled")
356
+ except Exception as e:
357
+ logger.error(f"Error in avatar main task: {e}")
358
+
359
+ def _cancel_active_segment_idle_end(self) -> None:
360
+ if self._active_segment_idle_end_task and not self._active_segment_idle_end_task.done():
361
+ self._active_segment_idle_end_task.cancel()
362
+ self._active_segment_idle_end_task = None
363
+
364
+ def _schedule_active_segment_idle_end(self) -> None:
365
+ active_req_id = self._active_req_id
366
+ if active_req_id is None:
367
+ return
368
+
369
+ self._cancel_active_segment_idle_end()
370
+ self._active_segment_idle_end_task = asyncio.create_task(
371
+ self._wait_for_active_segment_idle_end(active_req_id, ACTIVE_SEGMENT_IDLE_END_SECONDS),
372
+ name=f"spatialreal_idle_segment_end_{active_req_id}",
373
+ )
374
+
375
+ async def _wait_for_active_segment_idle_end(self, req_id: str, timeout: float) -> None:
376
+ try:
377
+ await asyncio.sleep(timeout)
378
+ except asyncio.CancelledError:
379
+ return
380
+
381
+ if self._active_req_id != req_id:
382
+ return
383
+
384
+ if req_id in self._pending_segment_ids:
385
+ return
386
+
387
+ if req_id not in self._segments:
388
+ return
389
+
390
+ if await self._finalize_active_segment(source="idle_timeout"):
391
+ logger.warning(
392
+ "Avatar: Segment end marker missing, forcing segment finalization "
393
+ f"(request_id={req_id}, idle_timeout={timeout:.2f}s)"
394
+ )
395
+
396
+ async def _finalize_active_segment(self, *, source: str) -> bool:
397
+ if self._active_req_id is None or not self._avatarkit_session:
398
+ return False
399
+
400
+ async with self._segment_finalize_lock:
401
+ active_req_id = self._active_req_id
402
+ if active_req_id is None:
403
+ return False
404
+
405
+ self._cancel_active_segment_idle_end()
406
+
407
+ req_id = await self._avatarkit_session.send_audio(
408
+ audio=b"",
409
+ end=True,
410
+ )
411
+
412
+ if req_id != active_req_id:
413
+ logger.warning(
414
+ "Avatar: Request ID changed while finalizing segment "
415
+ f"(expected={active_req_id}, actual={req_id}, source={source})"
416
+ )
417
+
418
+ self._active_req_id = None
419
+
420
+ active_segment = self._segments.pop(active_req_id, None)
421
+ segment = self._segments.get(req_id)
422
+
423
+ 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)
429
+ self._segments[req_id] = segment
430
+ elif active_segment is not None and segment is not active_segment:
431
+ segment.pushed_duration = max(segment.pushed_duration, active_segment.pushed_duration)
432
+ if segment.first_frame_at is None:
433
+ segment.first_frame_at = active_segment.first_frame_at
434
+
435
+ logger.debug(
436
+ "Avatar: Segment input completed "
437
+ f"(request_id={req_id}, duration={segment.pushed_duration:.3f}s, source={source})"
438
+ )
439
+ self._mark_segment_waiting_for_completion(segment)
440
+ return True
441
+
442
+ def _mark_segment_waiting_for_completion(self, segment: _SegmentState) -> None:
443
+ if segment.req_id not in self._pending_segment_ids:
444
+ self._pending_segment_ids.append(segment.req_id)
445
+
446
+ if segment.completion_timeout_task and not segment.completion_timeout_task.done():
447
+ segment.completion_timeout_task.cancel()
448
+
449
+ timeout = self._compute_completion_timeout(segment)
450
+ segment.completion_timeout_task = asyncio.create_task(
451
+ self._wait_for_segment_completion_timeout(segment.req_id, timeout),
452
+ name=f"spatialreal_segment_timeout_{segment.req_id}",
453
+ )
454
+
455
+ @staticmethod
456
+ def _compute_completion_timeout(segment: _SegmentState) -> float:
457
+ if segment.first_frame_at is None:
458
+ return MIN_COMPLETION_TIMEOUT_SECONDS
459
+
460
+ expected_playback_end = segment.first_frame_at + segment.pushed_duration
461
+ remaining_playback = max(0.0, expected_playback_end - time.time())
462
+ return max(
463
+ MIN_COMPLETION_TIMEOUT_SECONDS,
464
+ remaining_playback + COMPLETION_TIMEOUT_BUFFER_SECONDS,
465
+ )
466
+
467
+ async def _wait_for_segment_completion_timeout(self, req_id: str, timeout: float) -> None:
468
+ try:
469
+ await asyncio.sleep(timeout)
470
+ except asyncio.CancelledError:
471
+ return
472
+
473
+ if self._complete_segment(req_id=req_id, interrupted=False, reason="timeout"):
474
+ logger.warning(
475
+ "Avatar segment completion timed out, assuming playback finished "
476
+ f"(request_id={req_id}, timeout={timeout:.2f}s)"
477
+ )
478
+
479
+ def _on_transport_frame(self, frame: bytes, is_last: bool) -> None:
480
+ if not is_last:
481
+ return
482
+
483
+ req_id = self._extract_req_id_from_transport_frame(frame)
484
+ if req_id is not None:
485
+ if req_id not in self._pending_segment_ids:
486
+ logger.debug(
487
+ f"Avatar: ignoring provider completion before local segment finalization (request_id={req_id})"
488
+ )
489
+ return
490
+
491
+ if not self._complete_segment(req_id=req_id, interrupted=False, reason="provider_end"):
492
+ logger.debug(f"Avatar: completion event for unknown request_id={req_id}")
493
+ return
494
+
495
+ if self._pending_segment_ids:
496
+ fallback_req_id = self._pending_segment_ids[0]
497
+ if self._complete_segment(req_id=fallback_req_id, interrupted=False, reason="provider_end_fallback"):
498
+ logger.warning(
499
+ "Avatar: completion event missing request ID, matched oldest pending segment "
500
+ f"(request_id={fallback_req_id})"
501
+ )
502
+
503
+ def _on_clear_buffer(self) -> None:
504
+ asyncio.create_task(self._handle_interrupt())
505
+
506
+ @staticmethod
507
+ def _extract_req_id_from_transport_frame(frame: bytes) -> str | None:
508
+ try:
509
+ envelope = message_pb2.Message()
510
+ envelope.ParseFromString(frame)
511
+ except Exception:
512
+ return None
513
+
514
+ if envelope.type != message_pb2.MESSAGE_SERVER_RESPONSE_ANIMATION:
515
+ return None
516
+
517
+ req_id = envelope.server_response_animation.req_id
518
+ return req_id or None
519
+
520
+ def _complete_segment(self, *, req_id: str, interrupted: bool, reason: str) -> bool:
521
+ segment = self._segments.pop(req_id, None)
522
+ if segment is None:
523
+ return False
524
+
525
+ self._pending_segment_ids = deque(
526
+ pending_req_id for pending_req_id in self._pending_segment_ids if pending_req_id != req_id
527
+ )
528
+
529
+ if segment.completion_timeout_task and not segment.completion_timeout_task.done():
530
+ segment.completion_timeout_task.cancel()
531
+
532
+ if self._active_req_id == req_id:
533
+ self._active_req_id = None
534
+ self._cancel_active_segment_idle_end()
535
+
536
+ playback_position = (
537
+ self._estimate_interrupted_playback_position(segment) if interrupted else segment.pushed_duration
538
+ )
539
+
540
+ if self._audio_buffer:
541
+ self._audio_buffer.notify_playback_finished(
542
+ playback_position=playback_position,
543
+ interrupted=interrupted,
544
+ )
545
+
546
+ logger.debug(
547
+ "Avatar: Segment playback completed "
548
+ f"(request_id={req_id}, reason={reason}, interrupted={interrupted}, "
549
+ f"playback_position={playback_position:.3f}s, pushed_duration={segment.pushed_duration:.3f}s)"
550
+ )
551
+ return True
552
+
553
+ @staticmethod
554
+ def _estimate_interrupted_playback_position(segment: _SegmentState) -> float:
555
+ if segment.first_frame_at is None:
556
+ return 0.0
557
+
558
+ elapsed = max(0.0, time.time() - segment.first_frame_at)
559
+ return min(segment.pushed_duration, elapsed)
560
+
561
+ def _complete_all_segments(self, *, interrupted: bool, reason: str) -> None:
562
+ for req_id in list(self._segments.keys()):
563
+ self._complete_segment(req_id=req_id, interrupted=interrupted, reason=reason)
564
+
565
+ self._active_req_id = None
566
+ self._cancel_active_segment_idle_end()
567
+ self._pending_segment_ids.clear()
568
+
569
+ async def _handle_interrupt(self) -> None:
570
+ """Handle interruption - stop avatar's current audio processing."""
571
+ if not self._avatarkit_session:
572
+ return
573
+
574
+ try:
575
+ interrupted_id = await self._avatarkit_session.interrupt()
576
+
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")
581
+
582
+ logger.debug(f"Avatar interrupted, request_id={interrupted_id}")
583
+ except Exception as e:
584
+ logger.warning(f"Failed to interrupt avatar: {e}")
585
+
586
+ async def aclose(self) -> None:
587
+ """Clean up avatar session resources."""
588
+ if self._main_task:
589
+ self._main_task.cancel()
590
+ try:
591
+ await self._main_task
592
+ except asyncio.CancelledError:
593
+ pass
594
+ self._main_task = None
595
+
596
+ self._cancel_active_segment_idle_end()
597
+
598
+ self._complete_all_segments(interrupted=True, reason="session_close")
599
+
600
+ if (
601
+ self._agent_session
602
+ and self._audio_buffer
603
+ and self._audio_output_attached
604
+ and self._agent_session.output.audio is self._audio_buffer
605
+ ):
606
+ self._agent_session.output.audio = self._original_audio_output
607
+ self._audio_output_attached = False
608
+ self._original_audio_output = None
609
+
610
+ if self._audio_buffer:
611
+ await self._audio_buffer.aclose()
612
+ self._audio_buffer = None
613
+
614
+ if self._avatarkit_session:
615
+ try:
616
+ await self._avatarkit_session.close()
617
+ logger.info("Avatar session closed")
618
+ except Exception as e:
619
+ logger.warning(f"Error closing avatar session: {e}")
620
+ finally:
621
+ self._avatarkit_session = None
622
+
623
+ self._initialized = False
624
+ self._agent_session = None
625
+ self._audio_output_attached = False
626
+ self._original_audio_output = None
@@ -0,0 +1 @@
1
+ __version__ = "1.4.4"
@@ -1,297 +0,0 @@
1
- """
2
- SpatialReal Avatar integration for LiveKit Agents.
3
-
4
- This module provides AvatarSession which hooks into an AgentSession
5
- to route TTS audio to the SpatialReal avatar service.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import asyncio
11
- import os
12
- from datetime import datetime, timedelta, timezone
13
- from typing import Any
14
-
15
- from avatarkit import (
16
- AvatarSession as AvatarkitSession,
17
- )
18
- from avatarkit import (
19
- LiveKitEgressConfig,
20
- new_avatar_session,
21
- )
22
- from livekit.agents import AgentSession, UserStateChangedEvent
23
- from livekit.agents.voice.avatar import AudioSegmentEnd, QueueAudioOutput
24
-
25
- from livekit import rtc
26
-
27
- from .log import logger
28
-
29
- __all__ = ["AvatarSession", "SpatialRealException"]
30
-
31
- DEFAULT_AVATAR_PARTICIPANT_IDENTITY = "spatialreal-avatar"
32
- DEFAULT_SAMPLE_RATE = 24000
33
-
34
- DEFAULT_CONSOLE_ENDPOINT = "https://console.us-west.spatialwalk.cloud/v1/console"
35
- DEFAULT_INGRESS_ENDPOINT = "wss://api.us-west.spatialwalk.cloud/v2/driveningress"
36
-
37
-
38
- class SpatialRealException(Exception):
39
- """Exception raised for SpatialReal-related errors."""
40
-
41
- pass
42
-
43
-
44
- class AvatarSession:
45
- """
46
- This connects to SpatialReal's avatar service and routes TTS audio
47
- from the agent to the avatar for lip-synced rendering. The avatar
48
- service joins the LiveKit room and publishes synchronized video + audio.
49
-
50
- Args:
51
- api_key: SpatialReal API key. Falls back to SPATIALREAL_API_KEY env var.
52
- app_id: SpatialReal application ID. Falls back to SPATIALREAL_APP_ID env var.
53
- avatar_id: Avatar ID to use. Falls back to SPATIALREAL_AVATAR_ID env var.
54
- console_endpoint_url: Console endpoint URL. Falls back to
55
- SPATIALREAL_CONSOLE_ENDPOINT env var or default.
56
- ingress_endpoint_url: Ingress endpoint URL. Falls back to
57
- SPATIALREAL_INGRESS_ENDPOINT env var or default.
58
- avatar_participant_identity: LiveKit identity for the avatar participant.
59
- idle_timeout_seconds: Idle timeout in seconds for the egress connection.
60
- A value of 0 uses server defaults.
61
-
62
- Usage:
63
- avatar = AvatarSession()
64
- await avatar.start(session, room=ctx.room)
65
- """
66
-
67
- def __init__(
68
- self,
69
- *,
70
- api_key: str | None = None,
71
- app_id: str | None = None,
72
- avatar_id: str | None = None,
73
- console_endpoint_url: str | None = None,
74
- ingress_endpoint_url: str | None = None,
75
- avatar_participant_identity: str | None = None,
76
- idle_timeout_seconds: int = 0,
77
- ) -> None:
78
- # Resolve API key
79
- self._api_key = api_key or os.getenv("SPATIALREAL_API_KEY")
80
- if not self._api_key:
81
- raise SpatialRealException(
82
- "api_key must be provided or SPATIALREAL_API_KEY environment variable must be set"
83
- )
84
-
85
- # Resolve app ID
86
- self._app_id = app_id or os.getenv("SPATIALREAL_APP_ID")
87
- if not self._app_id:
88
- raise SpatialRealException("app_id must be provided or SPATIALREAL_APP_ID environment variable must be set")
89
-
90
- # Resolve avatar ID
91
- self._avatar_id = avatar_id or os.getenv("SPATIALREAL_AVATAR_ID")
92
- if not self._avatar_id:
93
- raise SpatialRealException(
94
- "avatar_id must be provided or SPATIALREAL_AVATAR_ID environment variable must be set"
95
- )
96
-
97
- # Resolve endpoints
98
- self._console_endpoint_url = (
99
- console_endpoint_url or os.getenv("SPATIALREAL_CONSOLE_ENDPOINT") or DEFAULT_CONSOLE_ENDPOINT
100
- )
101
- self._ingress_endpoint_url = (
102
- ingress_endpoint_url or os.getenv("SPATIALREAL_INGRESS_ENDPOINT") or DEFAULT_INGRESS_ENDPOINT
103
- )
104
-
105
- # Avatar participant configuration
106
- self._avatar_participant_identity = avatar_participant_identity or DEFAULT_AVATAR_PARTICIPANT_IDENTITY
107
-
108
- if idle_timeout_seconds < 0:
109
- raise SpatialRealException("idle_timeout_seconds must be greater than or equal to 0")
110
- self._idle_timeout_seconds = idle_timeout_seconds
111
-
112
- # Internal state
113
- self._avatarkit_session: AvatarkitSession | None = None
114
- self._agent_session: AgentSession | None = None
115
- self._audio_buffer: QueueAudioOutput | None = None
116
- self._main_task: asyncio.Task | None = None
117
- self._initialized = False
118
-
119
- async def start(
120
- self,
121
- agent_session: AgentSession,
122
- room: rtc.Room,
123
- *,
124
- livekit_url: str | None = None,
125
- livekit_api_key: str | None = None,
126
- livekit_api_secret: str | None = None,
127
- ) -> None:
128
- """
129
- Start the avatar session and hook into the agent session.
130
-
131
- Args:
132
- agent_session: The AgentSession to hook into for TTS audio.
133
- room: The LiveKit room for egress configuration.
134
- livekit_url: LiveKit server URL. Falls back to LIVEKIT_URL env var.
135
- livekit_api_key: LiveKit API key. Falls back to LIVEKIT_API_KEY env var.
136
- livekit_api_secret: LiveKit API secret. Falls back to LIVEKIT_API_SECRET env var.
137
- """
138
- if self._initialized:
139
- logger.warning("Avatar session already initialized")
140
- return
141
-
142
- self._agent_session = agent_session
143
-
144
- # Resolve LiveKit credentials
145
- lk_url = livekit_url or os.getenv("LIVEKIT_URL")
146
- lk_api_key = livekit_api_key or os.getenv("LIVEKIT_API_KEY")
147
- lk_api_secret = livekit_api_secret or os.getenv("LIVEKIT_API_SECRET")
148
-
149
- if not lk_url or not lk_api_key or not lk_api_secret:
150
- raise SpatialRealException(
151
- "livekit_url, livekit_api_key, and livekit_api_secret must be provided "
152
- "or LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET environment variables must be set"
153
- )
154
-
155
- room_name = room.name
156
- agent_participant_identity = room.local_participant.identity
157
- logger.info(f"Initializing SpatialReal avatar session for room: {room_name}")
158
- logger.debug(f"Console endpoint: {self._console_endpoint_url}")
159
- logger.debug(f"Ingress endpoint: {self._ingress_endpoint_url}")
160
-
161
- egress_attributes = {"lk.publish_on_behalf": agent_participant_identity}
162
-
163
- # Create LiveKit egress configuration for the avatar to join the room
164
- livekit_egress_kwargs: dict[str, Any] = {
165
- "url": lk_url,
166
- "api_key": lk_api_key,
167
- "api_secret": lk_api_secret,
168
- "room_name": room_name,
169
- "publisher_id": self._avatar_participant_identity,
170
- "extra_attributes": egress_attributes,
171
- "idle_timeout": self._idle_timeout_seconds,
172
- }
173
- livekit_egress = LiveKitEgressConfig(**livekit_egress_kwargs)
174
-
175
- # Create avatar session with LiveKit egress mode
176
- self._avatarkit_session = new_avatar_session(
177
- api_key=self._api_key,
178
- app_id=self._app_id,
179
- avatar_id=self._avatar_id,
180
- console_endpoint_url=self._console_endpoint_url,
181
- ingress_endpoint_url=self._ingress_endpoint_url,
182
- expire_at=datetime.now(timezone.utc) + timedelta(hours=1),
183
- livekit_egress=livekit_egress,
184
- )
185
-
186
- # Initialize and start the avatar session
187
- await self._avatarkit_session.init()
188
- await self._avatarkit_session.start()
189
- logger.info("SpatialReal avatar session connected")
190
-
191
- # Create audio buffer using livekit-agents' QueueAudioOutput
192
- sample_rate = agent_session.tts.sample_rate if agent_session.tts else DEFAULT_SAMPLE_RATE
193
- self._audio_buffer = QueueAudioOutput(sample_rate=sample_rate)
194
-
195
- # Hook into agent session's audio output
196
- agent_session.output.audio = self._audio_buffer
197
-
198
- # Start the audio buffer
199
- await self._audio_buffer.start()
200
-
201
- # Register for clear_buffer events (interruptions)
202
- @self._audio_buffer.on("clear_buffer")
203
- def on_clear_buffer() -> None:
204
- asyncio.create_task(self._handle_interrupt())
205
-
206
- # Register for user_state_changed events (interrupt on user speaking)
207
- @agent_session.on("user_state_changed")
208
- def on_user_state_changed(ev: UserStateChangedEvent) -> None:
209
- if ev.new_state == "speaking":
210
- asyncio.create_task(self._handle_interrupt())
211
-
212
- # Start the main task that forwards audio to avatar
213
- self._main_task = asyncio.create_task(self._run_main_task())
214
-
215
- self._initialized = True
216
- logger.info("Avatar audio output attached to agent session")
217
-
218
- # Register cleanup on session close
219
- @agent_session.on("close")
220
- def on_session_close() -> None:
221
- asyncio.create_task(self.aclose())
222
-
223
- async def _run_main_task(self) -> None:
224
- """Main task that forwards audio from the buffer to the avatar service."""
225
- if not self._audio_buffer or not self._avatarkit_session:
226
- return
227
-
228
- try:
229
- frame_count = 0
230
- async for item in self._audio_buffer:
231
- if isinstance(item, rtc.AudioFrame):
232
- # Convert AudioFrame to bytes and send to avatar
233
- audio_bytes = bytes(item.data)
234
- frame_count += 1
235
-
236
- if frame_count == 1:
237
- logger.debug("Avatar: First audio frame received")
238
-
239
- await self._avatarkit_session.send_audio(
240
- audio=audio_bytes,
241
- end=False,
242
- )
243
-
244
- elif isinstance(item, AudioSegmentEnd):
245
- # End of audio segment - signal completion to avatar
246
- logger.debug(f"Avatar: Segment end, sent {frame_count} frames")
247
- await self._avatarkit_session.send_audio(
248
- audio=b"",
249
- end=True,
250
- )
251
-
252
- # Notify the buffer that playback is finished
253
- self._audio_buffer.notify_playback_finished(
254
- playback_position=0.0,
255
- interrupted=False,
256
- )
257
- frame_count = 0
258
-
259
- except asyncio.CancelledError:
260
- logger.debug("Avatar main task cancelled")
261
- except Exception as e:
262
- logger.error(f"Error in avatar main task: {e}")
263
-
264
- async def _handle_interrupt(self) -> None:
265
- """Handle interruption - stop avatar's current audio processing."""
266
- if not self._avatarkit_session:
267
- return
268
-
269
- try:
270
- interrupted_id = await self._avatarkit_session.interrupt()
271
- logger.debug(f"Avatar interrupted, request_id={interrupted_id}")
272
- except Exception as e:
273
- logger.warning(f"Failed to interrupt avatar: {e}")
274
-
275
- async def aclose(self) -> None:
276
- """Clean up avatar session resources."""
277
- if self._main_task:
278
- self._main_task.cancel()
279
- try:
280
- await self._main_task
281
- except asyncio.CancelledError:
282
- pass
283
- self._main_task = None
284
-
285
- if self._audio_buffer:
286
- await self._audio_buffer.aclose()
287
- self._audio_buffer = None
288
-
289
- if self._avatarkit_session:
290
- try:
291
- await self._avatarkit_session.close()
292
- logger.info("Avatar session closed")
293
- except Exception as e:
294
- logger.warning(f"Error closing avatar session: {e}")
295
- finally:
296
- self._avatarkit_session = None
297
- self._initialized = False
@@ -1 +0,0 @@
1
- __version__ = "1.4.3"