livekit-plugins-spatialreal 1.4.0__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.
- {livekit_plugins_spatialreal-1.4.0 → livekit_plugins_spatialreal-1.4.4}/PKG-INFO +18 -3
- {livekit_plugins_spatialreal-1.4.0 → livekit_plugins_spatialreal-1.4.4}/README.md +16 -1
- livekit_plugins_spatialreal-1.4.4/livekit/plugins/spatialreal/avatar.py +626 -0
- livekit_plugins_spatialreal-1.4.4/livekit/plugins/spatialreal/version.py +1 -0
- {livekit_plugins_spatialreal-1.4.0 → livekit_plugins_spatialreal-1.4.4}/pyproject.toml +1 -1
- livekit_plugins_spatialreal-1.4.0/livekit/plugins/spatialreal/avatar.py +0 -283
- livekit_plugins_spatialreal-1.4.0/livekit/plugins/spatialreal/version.py +0 -1
- {livekit_plugins_spatialreal-1.4.0 → livekit_plugins_spatialreal-1.4.4}/.gitignore +0 -0
- {livekit_plugins_spatialreal-1.4.0 → livekit_plugins_spatialreal-1.4.4}/LICENSE +0 -0
- {livekit_plugins_spatialreal-1.4.0 → livekit_plugins_spatialreal-1.4.4}/livekit/plugins/spatialreal/__init__.py +0 -0
- {livekit_plugins_spatialreal-1.4.0 → livekit_plugins_spatialreal-1.4.4}/livekit/plugins/spatialreal/log.py +0 -0
- {livekit_plugins_spatialreal-1.4.0 → livekit_plugins_spatialreal-1.4.4}/livekit/plugins/spatialreal/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: livekit-plugins-spatialreal
|
|
3
|
-
Version: 1.4.
|
|
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/
|
|
@@ -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.
|
|
25
|
+
Requires-Dist: avatarkit>=0.1.4
|
|
26
26
|
Requires-Dist: livekit-agents>=1.2.9
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
28
28
|
|
|
@@ -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`
|
|
@@ -115,12 +125,17 @@ Main class for integrating SpatialReal avatars with LiveKit agents.
|
|
|
115
125
|
| `console_endpoint_url` | `str` | Custom console endpoint URL |
|
|
116
126
|
| `ingress_endpoint_url` | `str` | Custom ingress endpoint URL |
|
|
117
127
|
| `avatar_participant_identity` | `str` | LiveKit identity for avatar participant |
|
|
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 |
|
|
118
130
|
|
|
119
131
|
#### Methods
|
|
120
132
|
|
|
121
|
-
- `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.
|
|
122
134
|
- `aclose()`: Clean up avatar session resources.
|
|
123
135
|
|
|
136
|
+
When starting, the plugin automatically sets `lk.publish_on_behalf` to the
|
|
137
|
+
agent participant identity for avatar worker association in LiveKit frontends.
|
|
138
|
+
|
|
124
139
|
### `SpatialRealException`
|
|
125
140
|
|
|
126
141
|
Exception raised for SpatialReal-related errors.
|
|
@@ -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`
|
|
@@ -87,12 +97,17 @@ Main class for integrating SpatialReal avatars with LiveKit agents.
|
|
|
87
97
|
| `console_endpoint_url` | `str` | Custom console endpoint URL |
|
|
88
98
|
| `ingress_endpoint_url` | `str` | Custom ingress endpoint URL |
|
|
89
99
|
| `avatar_participant_identity` | `str` | LiveKit identity for avatar participant |
|
|
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 |
|
|
90
102
|
|
|
91
103
|
#### Methods
|
|
92
104
|
|
|
93
|
-
- `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.
|
|
94
106
|
- `aclose()`: Clean up avatar session resources.
|
|
95
107
|
|
|
108
|
+
When starting, the plugin automatically sets `lk.publish_on_behalf` to the
|
|
109
|
+
agent participant identity for avatar worker association in LiveKit frontends.
|
|
110
|
+
|
|
96
111
|
### `SpatialRealException`
|
|
97
112
|
|
|
98
113
|
Exception raised for SpatialReal-related errors.
|
|
@@ -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,283 +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
|
-
|
|
14
|
-
from avatarkit import (
|
|
15
|
-
AvatarSession as AvatarkitSession,
|
|
16
|
-
)
|
|
17
|
-
from avatarkit import (
|
|
18
|
-
LiveKitEgressConfig,
|
|
19
|
-
new_avatar_session,
|
|
20
|
-
)
|
|
21
|
-
from livekit.agents import AgentSession, UserStateChangedEvent
|
|
22
|
-
from livekit.agents.voice.avatar import AudioSegmentEnd, QueueAudioOutput
|
|
23
|
-
|
|
24
|
-
from livekit import rtc
|
|
25
|
-
|
|
26
|
-
from .log import logger
|
|
27
|
-
|
|
28
|
-
__all__ = ["AvatarSession", "SpatialRealException"]
|
|
29
|
-
|
|
30
|
-
DEFAULT_AVATAR_PARTICIPANT_IDENTITY = "spatialreal-avatar"
|
|
31
|
-
DEFAULT_SAMPLE_RATE = 24000
|
|
32
|
-
|
|
33
|
-
DEFAULT_CONSOLE_ENDPOINT = "https://console.us-west.spatialwalk.cloud/v1/console"
|
|
34
|
-
DEFAULT_INGRESS_ENDPOINT = "wss://api.us-west.spatialwalk.cloud/v2/driveningress"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
class SpatialRealException(Exception):
|
|
38
|
-
"""Exception raised for SpatialReal-related errors."""
|
|
39
|
-
|
|
40
|
-
pass
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
class AvatarSession:
|
|
44
|
-
"""
|
|
45
|
-
This connects to SpatialReal's avatar service and routes TTS audio
|
|
46
|
-
from the agent to the avatar for lip-synced rendering. The avatar
|
|
47
|
-
service joins the LiveKit room and publishes synchronized video + audio.
|
|
48
|
-
|
|
49
|
-
Args:
|
|
50
|
-
api_key: SpatialReal API key. Falls back to SPATIALREAL_API_KEY env var.
|
|
51
|
-
app_id: SpatialReal application ID. Falls back to SPATIALREAL_APP_ID env var.
|
|
52
|
-
avatar_id: Avatar ID to use. Falls back to SPATIALREAL_AVATAR_ID env var.
|
|
53
|
-
console_endpoint_url: Console endpoint URL. Falls back to
|
|
54
|
-
SPATIALREAL_CONSOLE_ENDPOINT env var or default.
|
|
55
|
-
ingress_endpoint_url: Ingress endpoint URL. Falls back to
|
|
56
|
-
SPATIALREAL_INGRESS_ENDPOINT env var or default.
|
|
57
|
-
avatar_participant_identity: LiveKit identity for the avatar participant.
|
|
58
|
-
|
|
59
|
-
Usage:
|
|
60
|
-
avatar = AvatarSession()
|
|
61
|
-
await avatar.start(session, room=ctx.room)
|
|
62
|
-
"""
|
|
63
|
-
|
|
64
|
-
def __init__(
|
|
65
|
-
self,
|
|
66
|
-
*,
|
|
67
|
-
api_key: str | None = None,
|
|
68
|
-
app_id: str | None = None,
|
|
69
|
-
avatar_id: str | None = None,
|
|
70
|
-
console_endpoint_url: str | None = None,
|
|
71
|
-
ingress_endpoint_url: str | None = None,
|
|
72
|
-
avatar_participant_identity: str | None = None,
|
|
73
|
-
) -> None:
|
|
74
|
-
# Resolve API key
|
|
75
|
-
self._api_key = api_key or os.getenv("SPATIALREAL_API_KEY")
|
|
76
|
-
if not self._api_key:
|
|
77
|
-
raise SpatialRealException(
|
|
78
|
-
"api_key must be provided or SPATIALREAL_API_KEY environment variable must be set"
|
|
79
|
-
)
|
|
80
|
-
|
|
81
|
-
# Resolve app ID
|
|
82
|
-
self._app_id = app_id or os.getenv("SPATIALREAL_APP_ID")
|
|
83
|
-
if not self._app_id:
|
|
84
|
-
raise SpatialRealException("app_id must be provided or SPATIALREAL_APP_ID environment variable must be set")
|
|
85
|
-
|
|
86
|
-
# Resolve avatar ID
|
|
87
|
-
self._avatar_id = avatar_id or os.getenv("SPATIALREAL_AVATAR_ID")
|
|
88
|
-
if not self._avatar_id:
|
|
89
|
-
raise SpatialRealException(
|
|
90
|
-
"avatar_id must be provided or SPATIALREAL_AVATAR_ID environment variable must be set"
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
# Resolve endpoints
|
|
94
|
-
self._console_endpoint_url = (
|
|
95
|
-
console_endpoint_url or os.getenv("SPATIALREAL_CONSOLE_ENDPOINT") or DEFAULT_CONSOLE_ENDPOINT
|
|
96
|
-
)
|
|
97
|
-
self._ingress_endpoint_url = (
|
|
98
|
-
ingress_endpoint_url or os.getenv("SPATIALREAL_INGRESS_ENDPOINT") or DEFAULT_INGRESS_ENDPOINT
|
|
99
|
-
)
|
|
100
|
-
|
|
101
|
-
# Avatar participant configuration
|
|
102
|
-
self._avatar_participant_identity = avatar_participant_identity or DEFAULT_AVATAR_PARTICIPANT_IDENTITY
|
|
103
|
-
|
|
104
|
-
# Internal state
|
|
105
|
-
self._avatarkit_session: AvatarkitSession | None = None
|
|
106
|
-
self._agent_session: AgentSession | None = None
|
|
107
|
-
self._audio_buffer: QueueAudioOutput | None = None
|
|
108
|
-
self._main_task: asyncio.Task | None = None
|
|
109
|
-
self._initialized = False
|
|
110
|
-
|
|
111
|
-
async def start(
|
|
112
|
-
self,
|
|
113
|
-
agent_session: AgentSession,
|
|
114
|
-
room: rtc.Room,
|
|
115
|
-
*,
|
|
116
|
-
livekit_url: str | None = None,
|
|
117
|
-
livekit_api_key: str | None = None,
|
|
118
|
-
livekit_api_secret: str | None = None,
|
|
119
|
-
) -> None:
|
|
120
|
-
"""
|
|
121
|
-
Start the avatar session and hook into the agent session.
|
|
122
|
-
|
|
123
|
-
Args:
|
|
124
|
-
agent_session: The AgentSession to hook into for TTS audio.
|
|
125
|
-
room: The LiveKit room for egress configuration.
|
|
126
|
-
livekit_url: LiveKit server URL. Falls back to LIVEKIT_URL env var.
|
|
127
|
-
livekit_api_key: LiveKit API key. Falls back to LIVEKIT_API_KEY env var.
|
|
128
|
-
livekit_api_secret: LiveKit API secret. Falls back to LIVEKIT_API_SECRET env var.
|
|
129
|
-
"""
|
|
130
|
-
if self._initialized:
|
|
131
|
-
logger.warning("Avatar session already initialized")
|
|
132
|
-
return
|
|
133
|
-
|
|
134
|
-
self._agent_session = agent_session
|
|
135
|
-
|
|
136
|
-
# Resolve LiveKit credentials
|
|
137
|
-
lk_url = livekit_url or os.getenv("LIVEKIT_URL")
|
|
138
|
-
lk_api_key = livekit_api_key or os.getenv("LIVEKIT_API_KEY")
|
|
139
|
-
lk_api_secret = livekit_api_secret or os.getenv("LIVEKIT_API_SECRET")
|
|
140
|
-
|
|
141
|
-
if not lk_url or not lk_api_key or not lk_api_secret:
|
|
142
|
-
raise SpatialRealException(
|
|
143
|
-
"livekit_url, livekit_api_key, and livekit_api_secret must be provided "
|
|
144
|
-
"or LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET environment variables must be set"
|
|
145
|
-
)
|
|
146
|
-
|
|
147
|
-
room_name = room.name
|
|
148
|
-
logger.info(f"Initializing SpatialReal avatar session for room: {room_name}")
|
|
149
|
-
logger.debug(f"Console endpoint: {self._console_endpoint_url}")
|
|
150
|
-
logger.debug(f"Ingress endpoint: {self._ingress_endpoint_url}")
|
|
151
|
-
|
|
152
|
-
# Create LiveKit egress configuration for the avatar to join the room
|
|
153
|
-
livekit_egress = LiveKitEgressConfig(
|
|
154
|
-
url=lk_url,
|
|
155
|
-
api_key=lk_api_key,
|
|
156
|
-
api_secret=lk_api_secret,
|
|
157
|
-
room_name=room_name,
|
|
158
|
-
publisher_id=self._avatar_participant_identity,
|
|
159
|
-
)
|
|
160
|
-
|
|
161
|
-
# Create avatar session with LiveKit egress mode
|
|
162
|
-
self._avatarkit_session = new_avatar_session(
|
|
163
|
-
api_key=self._api_key,
|
|
164
|
-
app_id=self._app_id,
|
|
165
|
-
avatar_id=self._avatar_id,
|
|
166
|
-
console_endpoint_url=self._console_endpoint_url,
|
|
167
|
-
ingress_endpoint_url=self._ingress_endpoint_url,
|
|
168
|
-
expire_at=datetime.now(timezone.utc) + timedelta(hours=1),
|
|
169
|
-
livekit_egress=livekit_egress,
|
|
170
|
-
)
|
|
171
|
-
|
|
172
|
-
# Initialize and start the avatar session
|
|
173
|
-
await self._avatarkit_session.init()
|
|
174
|
-
await self._avatarkit_session.start()
|
|
175
|
-
logger.info("SpatialReal avatar session connected")
|
|
176
|
-
|
|
177
|
-
# Create audio buffer using livekit-agents' QueueAudioOutput
|
|
178
|
-
sample_rate = agent_session.tts.sample_rate if agent_session.tts else DEFAULT_SAMPLE_RATE
|
|
179
|
-
self._audio_buffer = QueueAudioOutput(sample_rate=sample_rate)
|
|
180
|
-
|
|
181
|
-
# Hook into agent session's audio output
|
|
182
|
-
agent_session.output.audio = self._audio_buffer
|
|
183
|
-
|
|
184
|
-
# Start the audio buffer
|
|
185
|
-
await self._audio_buffer.start()
|
|
186
|
-
|
|
187
|
-
# Register for clear_buffer events (interruptions)
|
|
188
|
-
@self._audio_buffer.on("clear_buffer")
|
|
189
|
-
def on_clear_buffer() -> None:
|
|
190
|
-
asyncio.create_task(self._handle_interrupt())
|
|
191
|
-
|
|
192
|
-
# Register for user_state_changed events (interrupt on user speaking)
|
|
193
|
-
@agent_session.on("user_state_changed")
|
|
194
|
-
def on_user_state_changed(ev: UserStateChangedEvent) -> None:
|
|
195
|
-
if ev.new_state == "speaking":
|
|
196
|
-
asyncio.create_task(self._handle_interrupt())
|
|
197
|
-
|
|
198
|
-
# Start the main task that forwards audio to avatar
|
|
199
|
-
self._main_task = asyncio.create_task(self._run_main_task())
|
|
200
|
-
|
|
201
|
-
self._initialized = True
|
|
202
|
-
logger.info("Avatar audio output attached to agent session")
|
|
203
|
-
|
|
204
|
-
# Register cleanup on session close
|
|
205
|
-
@agent_session.on("close")
|
|
206
|
-
def on_session_close() -> None:
|
|
207
|
-
asyncio.create_task(self.aclose())
|
|
208
|
-
|
|
209
|
-
async def _run_main_task(self) -> None:
|
|
210
|
-
"""Main task that forwards audio from the buffer to the avatar service."""
|
|
211
|
-
if not self._audio_buffer or not self._avatarkit_session:
|
|
212
|
-
return
|
|
213
|
-
|
|
214
|
-
try:
|
|
215
|
-
frame_count = 0
|
|
216
|
-
async for item in self._audio_buffer:
|
|
217
|
-
if isinstance(item, rtc.AudioFrame):
|
|
218
|
-
# Convert AudioFrame to bytes and send to avatar
|
|
219
|
-
audio_bytes = bytes(item.data)
|
|
220
|
-
frame_count += 1
|
|
221
|
-
|
|
222
|
-
if frame_count == 1:
|
|
223
|
-
logger.debug("Avatar: First audio frame received")
|
|
224
|
-
|
|
225
|
-
await self._avatarkit_session.send_audio(
|
|
226
|
-
audio=audio_bytes,
|
|
227
|
-
end=False,
|
|
228
|
-
)
|
|
229
|
-
|
|
230
|
-
elif isinstance(item, AudioSegmentEnd):
|
|
231
|
-
# End of audio segment - signal completion to avatar
|
|
232
|
-
logger.debug(f"Avatar: Segment end, sent {frame_count} frames")
|
|
233
|
-
await self._avatarkit_session.send_audio(
|
|
234
|
-
audio=b"",
|
|
235
|
-
end=True,
|
|
236
|
-
)
|
|
237
|
-
|
|
238
|
-
# Notify the buffer that playback is finished
|
|
239
|
-
self._audio_buffer.notify_playback_finished(
|
|
240
|
-
playback_position=0.0,
|
|
241
|
-
interrupted=False,
|
|
242
|
-
)
|
|
243
|
-
frame_count = 0
|
|
244
|
-
|
|
245
|
-
except asyncio.CancelledError:
|
|
246
|
-
logger.debug("Avatar main task cancelled")
|
|
247
|
-
except Exception as e:
|
|
248
|
-
logger.error(f"Error in avatar main task: {e}")
|
|
249
|
-
|
|
250
|
-
async def _handle_interrupt(self) -> None:
|
|
251
|
-
"""Handle interruption - stop avatar's current audio processing."""
|
|
252
|
-
if not self._avatarkit_session:
|
|
253
|
-
return
|
|
254
|
-
|
|
255
|
-
try:
|
|
256
|
-
interrupted_id = await self._avatarkit_session.interrupt()
|
|
257
|
-
logger.debug(f"Avatar interrupted, request_id={interrupted_id}")
|
|
258
|
-
except Exception as e:
|
|
259
|
-
logger.warning(f"Failed to interrupt avatar: {e}")
|
|
260
|
-
|
|
261
|
-
async def aclose(self) -> None:
|
|
262
|
-
"""Clean up avatar session resources."""
|
|
263
|
-
if self._main_task:
|
|
264
|
-
self._main_task.cancel()
|
|
265
|
-
try:
|
|
266
|
-
await self._main_task
|
|
267
|
-
except asyncio.CancelledError:
|
|
268
|
-
pass
|
|
269
|
-
self._main_task = None
|
|
270
|
-
|
|
271
|
-
if self._audio_buffer:
|
|
272
|
-
await self._audio_buffer.aclose()
|
|
273
|
-
self._audio_buffer = None
|
|
274
|
-
|
|
275
|
-
if self._avatarkit_session:
|
|
276
|
-
try:
|
|
277
|
-
await self._avatarkit_session.close()
|
|
278
|
-
logger.info("Avatar session closed")
|
|
279
|
-
except Exception as e:
|
|
280
|
-
logger.warning(f"Error closing avatar session: {e}")
|
|
281
|
-
finally:
|
|
282
|
-
self._avatarkit_session = None
|
|
283
|
-
self._initialized = False
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "1.4.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|