livekit-plugins-spatius 1.4.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,216 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ # Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ # poetry.lock
109
+ # poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ # pdm.lock
116
+ # pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ # pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # Redis
135
+ *.rdb
136
+ *.aof
137
+ *.pid
138
+
139
+ # RabbitMQ
140
+ mnesia/
141
+ rabbitmq/
142
+ rabbitmq-data/
143
+
144
+ # ActiveMQ
145
+ activemq-data/
146
+
147
+ # SageMath parsed files
148
+ *.sage.py
149
+
150
+ # Environments
151
+ .env
152
+ .envrc
153
+ .venv
154
+ env/
155
+ venv/
156
+ ENV/
157
+ env.bak/
158
+ venv.bak/
159
+
160
+ # Spyder project settings
161
+ .spyderproject
162
+ .spyproject
163
+
164
+ # Rope project settings
165
+ .ropeproject
166
+
167
+ # mkdocs documentation
168
+ /site
169
+
170
+ # mypy
171
+ .mypy_cache/
172
+ .dmypy.json
173
+ dmypy.json
174
+
175
+ # Pyre type checker
176
+ .pyre/
177
+
178
+ # pytype static type analyzer
179
+ .pytype/
180
+
181
+ # Cython debug symbols
182
+ cython_debug/
183
+
184
+ # PyCharm
185
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
186
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
187
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
188
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
189
+ # .idea/
190
+
191
+ # Abstra
192
+ # Abstra is an AI-powered process automation framework.
193
+ # Ignore directories containing user credentials, local state, and settings.
194
+ # Learn more at https://abstra.io/docs
195
+ .abstra/
196
+
197
+ # Visual Studio Code
198
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
199
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
200
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
201
+ # you could uncomment the following to ignore the entire vscode folder
202
+ # .vscode/
203
+
204
+ # Ruff stuff:
205
+ .ruff_cache/
206
+
207
+ # PyPI configuration file
208
+ .pypirc
209
+
210
+ # Marimo
211
+ marimo/_static/
212
+ marimo/_lsp/
213
+ __marimo__/
214
+
215
+ # Streamlit
216
+ .streamlit/secrets.toml
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Spatius AI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: livekit-plugins-spatius
3
+ Version: 1.4.5
4
+ Summary: Agent Framework plugin for Spatius Avatar
5
+ Project-URL: Documentation, https://docs.spatius.ai
6
+ Project-URL: Website, https://www.spatius.ai/
7
+ Project-URL: Source, https://github.com/spatius-ai/livekit-plugins-spatius
8
+ Author-email: 3DRX <3drxkjy@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai,audio,avatar,livekit,realtime,spatius,video,voice,webrtc
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Multimedia :: Sound/Audio
22
+ Classifier: Topic :: Multimedia :: Video
23
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
24
+ Requires-Python: >=3.10.0
25
+ Requires-Dist: livekit-agents>=1.2.9
26
+ Requires-Dist: spatius>=1.0.0
27
+ Description-Content-Type: text/markdown
28
+
29
+ # LiveKit Agents Plugin for Spatius Avatar
30
+
31
+ LiveKit Agents plugin for [Spatius](https://www.spatius.ai) avatar sessions. It forwards TTS audio from a LiveKit agent session to Spatius and lets the avatar publish synchronized audio/video back into the same room.
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ pip install livekit-plugins-spatius
37
+ ```
38
+
39
+ ## Quick Start
40
+
41
+ Set credentials:
42
+
43
+ ```bash
44
+ export SPATIUS_API_KEY=your-api-key
45
+ export SPATIUS_APP_ID=your-app-id
46
+ export SPATIUS_AVATAR_ID=your-avatar-id
47
+
48
+ export LIVEKIT_URL=wss://your-livekit-host
49
+ export LIVEKIT_API_KEY=your-livekit-api-key
50
+ export LIVEKIT_API_SECRET=your-livekit-api-secret
51
+ ```
52
+
53
+ Use plugin in your LiveKit agent:
54
+
55
+ ```python
56
+ from livekit.agents import Agent, AgentSession, JobContext, WorkerOptions, cli
57
+ from livekit.plugins import spatius
58
+
59
+
60
+ class VoiceAssistant(Agent):
61
+ def __init__(self) -> None:
62
+ super().__init__(instructions="You are a helpful voice assistant.")
63
+
64
+
65
+ async def entrypoint(ctx: JobContext) -> None:
66
+ await ctx.connect()
67
+
68
+ session = AgentSession(
69
+ vad=vad,
70
+ stt=stt,
71
+ llm=llm,
72
+ tts=tts,
73
+ )
74
+
75
+ avatar = spatius.AvatarSession()
76
+ await avatar.start(session, room=ctx.room)
77
+
78
+ await session.start(agent=VoiceAssistant(), room=ctx.room)
79
+
80
+
81
+ if __name__ == "__main__":
82
+ cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
83
+ ```
84
+
85
+ `AvatarSession` defaults to `region="us-west"` and composes Spatius endpoints from that region. To use another region:
86
+
87
+ ```python
88
+ avatar = spatius.AvatarSession(region="us-east")
89
+ ```
90
+
91
+ Explicit endpoint URLs still override region:
92
+
93
+ ```python
94
+ avatar = spatius.AvatarSession(
95
+ console_endpoint_url="https://console.example.com/v1/console",
96
+ ingress_endpoint_url="wss://api.example.com/v2/driveningress",
97
+ )
98
+ ```
99
+
100
+ For detailed usage, see [Spatius docs](https://docs.spatius.ai).
101
+
102
+ ## License
103
+
104
+ MIT
@@ -0,0 +1,76 @@
1
+ # LiveKit Agents Plugin for Spatius Avatar
2
+
3
+ LiveKit Agents plugin for [Spatius](https://www.spatius.ai) avatar sessions. It forwards TTS audio from a LiveKit agent session to Spatius and lets the avatar publish synchronized audio/video back into the same room.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install livekit-plugins-spatius
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ Set credentials:
14
+
15
+ ```bash
16
+ export SPATIUS_API_KEY=your-api-key
17
+ export SPATIUS_APP_ID=your-app-id
18
+ export SPATIUS_AVATAR_ID=your-avatar-id
19
+
20
+ export LIVEKIT_URL=wss://your-livekit-host
21
+ export LIVEKIT_API_KEY=your-livekit-api-key
22
+ export LIVEKIT_API_SECRET=your-livekit-api-secret
23
+ ```
24
+
25
+ Use plugin in your LiveKit agent:
26
+
27
+ ```python
28
+ from livekit.agents import Agent, AgentSession, JobContext, WorkerOptions, cli
29
+ from livekit.plugins import spatius
30
+
31
+
32
+ class VoiceAssistant(Agent):
33
+ def __init__(self) -> None:
34
+ super().__init__(instructions="You are a helpful voice assistant.")
35
+
36
+
37
+ async def entrypoint(ctx: JobContext) -> None:
38
+ await ctx.connect()
39
+
40
+ session = AgentSession(
41
+ vad=vad,
42
+ stt=stt,
43
+ llm=llm,
44
+ tts=tts,
45
+ )
46
+
47
+ avatar = spatius.AvatarSession()
48
+ await avatar.start(session, room=ctx.room)
49
+
50
+ await session.start(agent=VoiceAssistant(), room=ctx.room)
51
+
52
+
53
+ if __name__ == "__main__":
54
+ cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
55
+ ```
56
+
57
+ `AvatarSession` defaults to `region="us-west"` and composes Spatius endpoints from that region. To use another region:
58
+
59
+ ```python
60
+ avatar = spatius.AvatarSession(region="us-east")
61
+ ```
62
+
63
+ Explicit endpoint URLs still override region:
64
+
65
+ ```python
66
+ avatar = spatius.AvatarSession(
67
+ console_endpoint_url="https://console.example.com/v1/console",
68
+ ingress_endpoint_url="wss://api.example.com/v2/driveningress",
69
+ )
70
+ ```
71
+
72
+ For detailed usage, see [Spatius docs](https://docs.spatius.ai).
73
+
74
+ ## License
75
+
76
+ MIT
@@ -0,0 +1,45 @@
1
+ """Spatius avatar plugin for LiveKit Agents.
2
+
3
+ This plugin provides integration with Spatius's avatar service for
4
+ lip-synced avatar rendering in LiveKit voice agents.
5
+
6
+ See https://docs.spatius.ai for more information.
7
+
8
+ Usage:
9
+ from livekit.plugins.spatius import AvatarSession
10
+
11
+ avatar = AvatarSession()
12
+ await avatar.start(agent_session, room=ctx.room)
13
+ """
14
+
15
+ from importlib.metadata import PackageNotFoundError, version
16
+
17
+ from .avatar import AvatarSession, SpatiusException
18
+
19
+ try:
20
+ __version__ = version("livekit-plugins-spatius")
21
+ except PackageNotFoundError:
22
+ __version__ = "0.0.0"
23
+
24
+ __all__ = [
25
+ "AvatarSession",
26
+ "SpatiusException",
27
+ "__version__",
28
+ ]
29
+
30
+ # Try to register plugin if Plugin class is available (livekit-agents >= 1.3)
31
+ try:
32
+ from livekit.agents import Plugin
33
+
34
+ from .log import logger
35
+
36
+ class SpatiusPlugin(Plugin):
37
+ """LiveKit plugin registration shim for Spatius avatar support."""
38
+
39
+ def __init__(self) -> None:
40
+ super().__init__(__name__, __version__, __package__, logger)
41
+
42
+ Plugin.register_plugin(SpatiusPlugin())
43
+ except (ImportError, AttributeError):
44
+ # Plugin registration not available in older versions
45
+ pass
@@ -0,0 +1,700 @@
1
+ """
2
+ Spatius Avatar integration for LiveKit Agents.
3
+
4
+ This module provides AvatarSession which hooks into an AgentSession
5
+ to route TTS audio to the Spatius 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 livekit.agents import AgentSession, UserStateChangedEvent, get_job_context
19
+ from livekit.agents.voice.avatar import AudioSegmentEnd, QueueAudioOutput
20
+ from livekit.agents.voice.room_io import ATTRIBUTE_PUBLISH_ON_BEHALF
21
+
22
+ from livekit import rtc
23
+ from spatius import AvatarSession as SpatiusSession
24
+ from spatius import LiveKitEgressConfig, new_avatar_session
25
+ from spatius.proto.generated import message_pb2 as _message_pb2
26
+
27
+ from .log import logger
28
+
29
+ message_pb2: Any = _message_pb2
30
+
31
+ __all__ = ["AvatarSession", "SpatiusException"]
32
+
33
+ DEFAULT_REGION = "us-west"
34
+ DEFAULT_AVATAR_PARTICIPANT_IDENTITY = "spatius-avatar"
35
+ DEFAULT_SAMPLE_RATE = 24000
36
+ MIN_COMPLETION_TIMEOUT_SECONDS = 3.0
37
+ COMPLETION_TIMEOUT_BUFFER_SECONDS = 2.0
38
+ ACTIVE_SEGMENT_IDLE_END_SECONDS = 1.0
39
+ DEFAULT_SESSION_TTL = timedelta(hours=1)
40
+ LIVEKIT_AVATAR_PUBLISH_SOURCES = ["camera", "microphone"]
41
+
42
+
43
+ class SpatiusException(Exception):
44
+ """Exception raised for Spatius-related errors."""
45
+
46
+ pass
47
+
48
+
49
+ @dataclass
50
+ class _SegmentState:
51
+ req_id: str
52
+ pushed_duration: float = 0.0
53
+ first_frame_at: float | None = None
54
+ completion_timeout_task: asyncio.Task[None] | None = None
55
+
56
+
57
+ class AvatarSession:
58
+ """
59
+ LiveKit Agents adapter for Spatius avatars.
60
+
61
+ This connects to Spatius's avatar service and routes TTS audio
62
+ from the agent to the avatar for lip-synced rendering. The avatar
63
+ service joins the LiveKit room and publishes synchronized video + audio.
64
+
65
+ Args:
66
+ api_key: Spatius API key. Falls back to ``SPATIUS_API_KEY``.
67
+ app_id: Spatius application ID. Falls back to ``SPATIUS_APP_ID``.
68
+ avatar_id: Avatar ID to use. Falls back to ``SPATIUS_AVATAR_ID``.
69
+ region: Spatius region used to compose endpoint URLs when explicit
70
+ endpoints are omitted. Falls back to ``SPATIUS_REGION`` or
71
+ ``"us-west"``.
72
+ console_endpoint_url: Optional explicit Console API URL. Falls back to
73
+ ``SPATIUS_CONSOLE_ENDPOINT`` and overrides ``region``.
74
+ ingress_endpoint_url: Optional explicit ingress WebSocket URL. Falls back
75
+ to ``SPATIUS_INGRESS_ENDPOINT`` and overrides ``region``.
76
+ avatar_participant_identity: LiveKit identity for the avatar participant.
77
+ idle_timeout_seconds: Idle timeout in seconds for the egress connection.
78
+ A value of 0 uses server defaults.
79
+ sample_rate: Optional audio sample rate override for avatar audio.
80
+ Falls back to agent_session.tts.sample_rate or a default value.
81
+
82
+ Usage:
83
+ avatar = AvatarSession()
84
+ await avatar.start(session, room=ctx.room)
85
+ """
86
+
87
+ def __init__(
88
+ self,
89
+ *,
90
+ api_key: str | None = None,
91
+ app_id: str | None = None,
92
+ avatar_id: str | None = None,
93
+ region: str | None = None,
94
+ console_endpoint_url: str | None = None,
95
+ ingress_endpoint_url: str | None = None,
96
+ avatar_participant_identity: str | None = None,
97
+ idle_timeout_seconds: int = 0,
98
+ sample_rate: int | None = None,
99
+ ) -> None:
100
+ # Resolve Spatius credentials and routing configuration.
101
+ self._api_key = api_key or os.getenv("SPATIUS_API_KEY")
102
+ if not self._api_key:
103
+ raise SpatiusException("api_key must be provided or SPATIUS_API_KEY environment variable must be set")
104
+
105
+ self._app_id = app_id or os.getenv("SPATIUS_APP_ID")
106
+ if not self._app_id:
107
+ raise SpatiusException("app_id must be provided or SPATIUS_APP_ID environment variable must be set")
108
+
109
+ self._avatar_id = avatar_id or os.getenv("SPATIUS_AVATAR_ID")
110
+ if not self._avatar_id:
111
+ raise SpatiusException("avatar_id must be provided or SPATIUS_AVATAR_ID environment variable must be set")
112
+
113
+ self._region = region or os.getenv("SPATIUS_REGION") or DEFAULT_REGION
114
+ self._console_endpoint_url = console_endpoint_url or os.getenv("SPATIUS_CONSOLE_ENDPOINT") or ""
115
+ self._ingress_endpoint_url = ingress_endpoint_url or os.getenv("SPATIUS_INGRESS_ENDPOINT") or ""
116
+
117
+ # Avatar participant configuration
118
+ self._avatar_participant_identity = avatar_participant_identity or DEFAULT_AVATAR_PARTICIPANT_IDENTITY
119
+
120
+ if idle_timeout_seconds < 0:
121
+ raise SpatiusException("idle_timeout_seconds must be greater than or equal to 0")
122
+ self._idle_timeout_seconds = idle_timeout_seconds
123
+
124
+ if sample_rate is not None and sample_rate <= 0:
125
+ raise SpatiusException("sample_rate must be greater than 0")
126
+ self._sample_rate = sample_rate
127
+
128
+ # Internal state
129
+ self._spatius_session: SpatiusSession | None = None
130
+ self._agent_session: AgentSession | None = None
131
+ self._audio_buffer: QueueAudioOutput | None = None
132
+ self._original_audio_output: Any | None = None
133
+ self._audio_output_attached = False
134
+ self._main_task: asyncio.Task | None = None
135
+ self._initialized = False
136
+ self._segments: dict[str, _SegmentState] = {}
137
+ self._pending_segment_ids: deque[str] = deque()
138
+ self._active_req_id: str | None = None
139
+ self._active_segment_idle_end_task: asyncio.Task[None] | None = None
140
+ self._segment_finalize_lock = asyncio.Lock()
141
+
142
+ async def start(
143
+ self,
144
+ agent_session: AgentSession,
145
+ room: rtc.Room,
146
+ *,
147
+ livekit_url: str | None = None,
148
+ livekit_api_key: str | None = None,
149
+ livekit_api_secret: str | None = None,
150
+ ) -> None:
151
+ """
152
+ Start the avatar session and hook into the agent session.
153
+
154
+ Args:
155
+ agent_session: The AgentSession to hook into for TTS audio.
156
+ room: The LiveKit room for egress configuration.
157
+ livekit_url: LiveKit server URL. Falls back to LIVEKIT_URL env var.
158
+ livekit_api_key: LiveKit API key. Falls back to LIVEKIT_API_KEY env var.
159
+ livekit_api_secret: LiveKit API secret. Falls back to LIVEKIT_API_SECRET env var.
160
+ """
161
+ if self._initialized:
162
+ logger.warning("Avatar session already initialized")
163
+ return
164
+
165
+ # Resolve LiveKit credentials
166
+ lk_url = livekit_url or os.getenv("LIVEKIT_URL")
167
+ lk_api_key = livekit_api_key or os.getenv("LIVEKIT_API_KEY")
168
+ lk_api_secret = livekit_api_secret or os.getenv("LIVEKIT_API_SECRET")
169
+
170
+ if not lk_url:
171
+ raise SpatiusException("livekit_url must be provided or LIVEKIT_URL environment variable must be set")
172
+
173
+ if not lk_api_key or not lk_api_secret:
174
+ raise SpatiusException(
175
+ "livekit_api_key and livekit_api_secret must be provided "
176
+ "or LIVEKIT_API_KEY and LIVEKIT_API_SECRET environment variables must be set"
177
+ )
178
+
179
+ room_name = room.name
180
+ agent_participant_identity = self._resolve_local_participant_identity(room)
181
+ logger.info(f"Initializing Spatius avatar session for room: {room_name}")
182
+ logger.debug(f"Region: {self._region}")
183
+ if self._console_endpoint_url:
184
+ logger.debug(f"Console endpoint override: {self._console_endpoint_url}")
185
+ if self._ingress_endpoint_url:
186
+ logger.debug(f"Ingress endpoint override: {self._ingress_endpoint_url}")
187
+
188
+ egress_attributes = {ATTRIBUTE_PUBLISH_ON_BEHALF: agent_participant_identity}
189
+ session_expire_at = datetime.now(timezone.utc) + DEFAULT_SESSION_TTL
190
+ resolved_lk_api_token = self._generate_livekit_api_token(
191
+ room_name=room_name,
192
+ livekit_api_key=lk_api_key,
193
+ livekit_api_secret=lk_api_secret,
194
+ publisher_identity=self._avatar_participant_identity,
195
+ publisher_attributes=egress_attributes,
196
+ ttl=DEFAULT_SESSION_TTL,
197
+ )
198
+
199
+ # Create LiveKit egress configuration for the avatar to join the room
200
+ livekit_egress_kwargs: dict[str, Any] = {
201
+ "url": lk_url,
202
+ "api_token": resolved_lk_api_token,
203
+ "room_name": room_name,
204
+ "publisher_id": self._avatar_participant_identity,
205
+ "extra_attributes": egress_attributes,
206
+ "idle_timeout": self._idle_timeout_seconds,
207
+ }
208
+ livekit_egress = LiveKitEgressConfig(**livekit_egress_kwargs)
209
+
210
+ resolved_sample_rate = self._sample_rate
211
+ if resolved_sample_rate is None:
212
+ resolved_sample_rate = agent_session.tts.sample_rate if agent_session.tts else DEFAULT_SAMPLE_RATE
213
+ if resolved_sample_rate <= 0:
214
+ raise SpatiusException("sample_rate must be greater than 0")
215
+
216
+ self._agent_session = agent_session
217
+ self._original_audio_output = agent_session.output.audio
218
+
219
+ try:
220
+ # Create avatar session with LiveKit egress mode
221
+ self._spatius_session = new_avatar_session(
222
+ api_key=self._api_key,
223
+ app_id=self._app_id,
224
+ avatar_id=self._avatar_id,
225
+ region=self._region,
226
+ console_endpoint_url=self._console_endpoint_url,
227
+ ingress_endpoint_url=self._ingress_endpoint_url,
228
+ expire_at=session_expire_at,
229
+ livekit_egress=livekit_egress,
230
+ sample_rate=resolved_sample_rate,
231
+ transport_frames=self._on_transport_frame,
232
+ )
233
+
234
+ # Initialize and start the avatar session
235
+ await self._spatius_session.init()
236
+ await self._spatius_session.start()
237
+ logger.info("Spatius avatar session connected")
238
+
239
+ # Create audio buffer using livekit-agents' QueueAudioOutput
240
+ self._audio_buffer = QueueAudioOutput(sample_rate=resolved_sample_rate)
241
+
242
+ # Hook into agent session's audio output
243
+ agent_session.output.audio = self._audio_buffer
244
+ self._audio_output_attached = True
245
+
246
+ # Start the audio buffer
247
+ await self._audio_buffer.start()
248
+
249
+ # Register for clear_buffer events (interruptions)
250
+ self._audio_buffer.on("clear_buffer", self._on_clear_buffer) # type: ignore[arg-type]
251
+
252
+ # Register for user_state_changed events (interrupt on user speaking)
253
+ @agent_session.on("user_state_changed")
254
+ def on_user_state_changed(ev: UserStateChangedEvent) -> None:
255
+ if ev.new_state == "speaking":
256
+ asyncio.create_task(self._handle_interrupt())
257
+
258
+ # Start the main task that forwards audio to avatar
259
+ self._main_task = asyncio.create_task(self._run_main_task())
260
+
261
+ self._initialized = True
262
+ logger.info("Avatar audio output attached to agent session")
263
+
264
+ # Register cleanup on session close
265
+ @agent_session.on("close")
266
+ def on_session_close() -> None:
267
+ asyncio.create_task(self.aclose())
268
+
269
+ except asyncio.CancelledError:
270
+ await self.aclose()
271
+ raise
272
+ except Exception as e:
273
+ logger.debug("Spatius avatar session startup failed", exc_info=True)
274
+ await self.aclose()
275
+ raise SpatiusException(
276
+ self._build_start_error_message(
277
+ error=e,
278
+ room_name=room_name,
279
+ sample_rate=resolved_sample_rate,
280
+ )
281
+ ) from None
282
+
283
+ def _build_start_error_message(
284
+ self,
285
+ *,
286
+ error: Exception,
287
+ room_name: str,
288
+ sample_rate: int,
289
+ ) -> str:
290
+ reason = self._format_error_reason(error)
291
+ return (
292
+ "Failed to start Spatius avatar session. "
293
+ "Check Spatius credentials, LiveKit room auth/token configuration, "
294
+ "region/endpoint URLs, and outbound network access. "
295
+ f"room={room_name}, avatar_id={self._avatar_id}, region={self._region}, "
296
+ f"sample_rate={sample_rate}. Reason: {reason}"
297
+ )
298
+
299
+ @staticmethod
300
+ def _generate_livekit_api_token(
301
+ *,
302
+ room_name: str,
303
+ livekit_api_key: str,
304
+ livekit_api_secret: str,
305
+ publisher_identity: str,
306
+ publisher_attributes: dict[str, str],
307
+ ttl: timedelta,
308
+ ) -> str:
309
+ try:
310
+ from livekit import api as livekit_api
311
+ except ImportError as exc:
312
+ raise SpatiusException(
313
+ "livekit-api must be installed to generate LiveKit access tokens for avatar egress"
314
+ ) from exc
315
+
316
+ try:
317
+ return (
318
+ livekit_api.AccessToken(livekit_api_key, livekit_api_secret)
319
+ .with_kind("agent")
320
+ .with_identity(publisher_identity)
321
+ .with_name(publisher_identity)
322
+ .with_ttl(ttl)
323
+ .with_attributes(publisher_attributes)
324
+ .with_grants(
325
+ livekit_api.VideoGrants(
326
+ room_join=True,
327
+ room=room_name,
328
+ can_subscribe=False,
329
+ can_publish_data=False,
330
+ can_publish_sources=LIVEKIT_AVATAR_PUBLISH_SOURCES,
331
+ )
332
+ )
333
+ .to_jwt()
334
+ )
335
+ except Exception as exc:
336
+ raise SpatiusException(
337
+ "Failed to generate LiveKit access token for avatar worker. "
338
+ f"room={room_name}, publisher_identity={publisher_identity}. "
339
+ f"Reason: {AvatarSession._format_error_reason(exc)}"
340
+ ) from exc
341
+
342
+ @staticmethod
343
+ def _resolve_local_participant_identity(room: rtc.Room) -> str:
344
+ try:
345
+ return get_job_context().token_claims().identity
346
+ except RuntimeError as exc:
347
+ if not room.isconnected():
348
+ raise SpatiusException("failed to get local participant identity") from exc
349
+ return room.local_participant.identity
350
+
351
+ @staticmethod
352
+ def _format_error_reason(error: BaseException) -> str:
353
+ root_error = error
354
+ seen_errors: set[int] = set()
355
+
356
+ while id(root_error) not in seen_errors:
357
+ seen_errors.add(id(root_error))
358
+ next_error = root_error.__cause__ or (None if root_error.__suppress_context__ else root_error.__context__)
359
+ if next_error is None:
360
+ break
361
+ root_error = next_error
362
+
363
+ message = str(root_error) or str(error)
364
+ if message:
365
+ return f"{type(root_error).__name__}: {message}"
366
+
367
+ return type(root_error).__name__
368
+
369
+ async def _run_main_task(self) -> None:
370
+ """Main task that forwards audio from the buffer to the avatar service."""
371
+ if not self._audio_buffer or not self._spatius_session:
372
+ return
373
+
374
+ try:
375
+ async for item in self._audio_buffer:
376
+ if isinstance(item, rtc.AudioFrame):
377
+ # Convert AudioFrame to bytes and send to avatar
378
+ audio_bytes = bytes(item.data)
379
+
380
+ previous_req_id = self._active_req_id
381
+
382
+ req_id = await self._spatius_session.send_audio(
383
+ audio=audio_bytes,
384
+ end=False,
385
+ )
386
+
387
+ if previous_req_id and previous_req_id != req_id:
388
+ logger.warning(
389
+ "Avatar: request ID changed while streaming audio "
390
+ f"(previous={previous_req_id}, current={req_id})"
391
+ )
392
+ previous_segment = self._segments.get(previous_req_id)
393
+ if previous_segment is not None:
394
+ self._mark_segment_waiting_for_completion(previous_segment)
395
+
396
+ segment = self._segments.get(req_id)
397
+ if segment is None:
398
+ segment = _SegmentState(req_id=req_id)
399
+ self._segments[req_id] = segment
400
+
401
+ if segment.first_frame_at is None:
402
+ segment.first_frame_at = time.time()
403
+ logger.debug(f"Avatar: First audio frame received (request_id={req_id})")
404
+
405
+ segment.pushed_duration += item.duration
406
+ self._active_req_id = req_id
407
+ self._schedule_active_segment_idle_end()
408
+
409
+ elif isinstance(item, AudioSegmentEnd):
410
+ # End of audio segment - signal completion to avatar
411
+ if not await self._finalize_active_segment(source="segment_end"):
412
+ logger.debug("Avatar: Segment end received without an active request")
413
+
414
+ except asyncio.CancelledError:
415
+ logger.debug("Avatar main task cancelled")
416
+ except Exception as e:
417
+ logger.error(f"Error in avatar main task: {e}")
418
+
419
+ def _cancel_active_segment_idle_end(self) -> None:
420
+ if self._active_segment_idle_end_task and not self._active_segment_idle_end_task.done():
421
+ self._active_segment_idle_end_task.cancel()
422
+ self._active_segment_idle_end_task = None
423
+
424
+ def _schedule_active_segment_idle_end(self) -> None:
425
+ active_req_id = self._active_req_id
426
+ if active_req_id is None:
427
+ return
428
+
429
+ self._cancel_active_segment_idle_end()
430
+ self._active_segment_idle_end_task = asyncio.create_task(
431
+ self._wait_for_active_segment_idle_end(active_req_id, ACTIVE_SEGMENT_IDLE_END_SECONDS),
432
+ name=f"spatius_idle_segment_end_{active_req_id}",
433
+ )
434
+
435
+ async def _wait_for_active_segment_idle_end(self, req_id: str, timeout: float) -> None:
436
+ try:
437
+ await asyncio.sleep(timeout)
438
+ except asyncio.CancelledError:
439
+ return
440
+
441
+ if self._active_req_id != req_id:
442
+ return
443
+
444
+ if req_id in self._pending_segment_ids:
445
+ return
446
+
447
+ if req_id not in self._segments:
448
+ return
449
+
450
+ if await self._finalize_active_segment(source="idle_timeout"):
451
+ logger.warning(
452
+ "Avatar: Segment end marker missing, forcing segment finalization "
453
+ f"(request_id={req_id}, idle_timeout={timeout:.2f}s)"
454
+ )
455
+
456
+ async def _finalize_active_segment(self, *, source: str) -> bool:
457
+ if self._active_req_id is None or not self._spatius_session:
458
+ return False
459
+
460
+ async with self._segment_finalize_lock:
461
+ active_req_id = self._active_req_id
462
+ if active_req_id is None:
463
+ return False
464
+
465
+ self._cancel_active_segment_idle_end()
466
+
467
+ req_id = await self._spatius_session.send_audio(
468
+ audio=b"",
469
+ end=True,
470
+ )
471
+
472
+ if req_id != active_req_id:
473
+ logger.warning(
474
+ "Avatar: Request ID changed while finalizing segment "
475
+ f"(expected={active_req_id}, actual={req_id}, source={source})"
476
+ )
477
+
478
+ self._active_req_id = None
479
+
480
+ active_segment = self._segments.pop(active_req_id, None)
481
+ segment = self._segments.get(req_id)
482
+
483
+ if active_segment is None and segment is None:
484
+ logger.debug(
485
+ "Avatar: Segment completed before finalization finished "
486
+ f"(request_id={active_req_id}, finalize_request_id={req_id}, source={source})"
487
+ )
488
+ return True
489
+
490
+ if segment is None:
491
+ if active_segment is None:
492
+ return True
493
+ active_segment.req_id = req_id
494
+ segment = active_segment
495
+ self._segments[req_id] = segment
496
+ elif active_segment is not None and segment is not active_segment:
497
+ segment.pushed_duration = max(segment.pushed_duration, active_segment.pushed_duration)
498
+ if segment.first_frame_at is None:
499
+ segment.first_frame_at = active_segment.first_frame_at
500
+
501
+ logger.debug(
502
+ "Avatar: Segment input completed "
503
+ f"(request_id={req_id}, duration={segment.pushed_duration:.3f}s, source={source})"
504
+ )
505
+ self._mark_segment_waiting_for_completion(segment)
506
+ return True
507
+
508
+ def _mark_segment_waiting_for_completion(self, segment: _SegmentState) -> None:
509
+ if segment.req_id not in self._pending_segment_ids:
510
+ self._pending_segment_ids.append(segment.req_id)
511
+
512
+ if segment.completion_timeout_task and not segment.completion_timeout_task.done():
513
+ segment.completion_timeout_task.cancel()
514
+
515
+ timeout = self._compute_completion_timeout(segment)
516
+ segment.completion_timeout_task = asyncio.create_task(
517
+ self._wait_for_segment_completion_timeout(segment.req_id, timeout),
518
+ name=f"spatius_segment_timeout_{segment.req_id}",
519
+ )
520
+
521
+ @staticmethod
522
+ def _compute_completion_timeout(segment: _SegmentState) -> float:
523
+ if segment.first_frame_at is None:
524
+ return MIN_COMPLETION_TIMEOUT_SECONDS
525
+
526
+ expected_playback_end = segment.first_frame_at + segment.pushed_duration
527
+ remaining_playback = max(0.0, expected_playback_end - time.time())
528
+ return max(
529
+ MIN_COMPLETION_TIMEOUT_SECONDS,
530
+ remaining_playback + COMPLETION_TIMEOUT_BUFFER_SECONDS,
531
+ )
532
+
533
+ async def _wait_for_segment_completion_timeout(self, req_id: str, timeout: float) -> None:
534
+ try:
535
+ await asyncio.sleep(timeout)
536
+ except asyncio.CancelledError:
537
+ return
538
+
539
+ if self._complete_segment(req_id=req_id, interrupted=False, reason="timeout"):
540
+ logger.warning(
541
+ "Avatar segment completion timed out, assuming playback finished "
542
+ f"(request_id={req_id}, timeout={timeout:.2f}s)"
543
+ )
544
+
545
+ def _on_transport_frame(self, frame: bytes, is_last: bool) -> None:
546
+ if not is_last:
547
+ return
548
+
549
+ req_id = self._extract_req_id_from_transport_frame(frame)
550
+ if req_id is not None:
551
+ if req_id not in self._pending_segment_ids:
552
+ logger.debug(
553
+ f"Avatar: ignoring provider completion before local segment finalization (request_id={req_id})"
554
+ )
555
+ return
556
+
557
+ if not self._complete_segment(req_id=req_id, interrupted=False, reason="provider_end"):
558
+ logger.debug(f"Avatar: completion event for unknown request_id={req_id}")
559
+ return
560
+
561
+ if self._pending_segment_ids:
562
+ fallback_req_id = self._pending_segment_ids[0]
563
+ if self._complete_segment(req_id=fallback_req_id, interrupted=False, reason="provider_end_fallback"):
564
+ logger.warning(
565
+ "Avatar: completion event missing request ID, matched oldest pending segment "
566
+ f"(request_id={fallback_req_id})"
567
+ )
568
+
569
+ def _on_clear_buffer(self) -> None:
570
+ asyncio.create_task(self._handle_interrupt())
571
+
572
+ @staticmethod
573
+ def _extract_req_id_from_transport_frame(frame: bytes) -> str | None:
574
+ try:
575
+ envelope = message_pb2.Message()
576
+ envelope.ParseFromString(frame)
577
+ except Exception:
578
+ return None
579
+
580
+ if envelope.type != message_pb2.MESSAGE_SERVER_RESPONSE_ANIMATION:
581
+ return None
582
+
583
+ req_id = envelope.server_response_animation.req_id
584
+ return req_id or None
585
+
586
+ def _complete_segment(self, *, req_id: str, interrupted: bool, reason: str) -> bool:
587
+ segment = self._segments.pop(req_id, None)
588
+ if segment is None:
589
+ return False
590
+
591
+ self._pending_segment_ids = deque(
592
+ pending_req_id for pending_req_id in self._pending_segment_ids if pending_req_id != req_id
593
+ )
594
+
595
+ if segment.completion_timeout_task and not segment.completion_timeout_task.done():
596
+ segment.completion_timeout_task.cancel()
597
+
598
+ if self._active_req_id == req_id:
599
+ self._active_req_id = None
600
+ self._cancel_active_segment_idle_end()
601
+
602
+ playback_position = (
603
+ self._estimate_interrupted_playback_position(segment) if interrupted else segment.pushed_duration
604
+ )
605
+
606
+ if self._audio_buffer:
607
+ self._audio_buffer.notify_playback_finished(
608
+ playback_position=playback_position,
609
+ interrupted=interrupted,
610
+ )
611
+
612
+ logger.debug(
613
+ "Avatar: Segment playback completed "
614
+ f"(request_id={req_id}, reason={reason}, interrupted={interrupted}, "
615
+ f"playback_position={playback_position:.3f}s, pushed_duration={segment.pushed_duration:.3f}s)"
616
+ )
617
+ return True
618
+
619
+ @staticmethod
620
+ def _estimate_interrupted_playback_position(segment: _SegmentState) -> float:
621
+ if segment.first_frame_at is None:
622
+ return 0.0
623
+
624
+ elapsed = max(0.0, time.time() - segment.first_frame_at)
625
+ return min(segment.pushed_duration, elapsed)
626
+
627
+ def _complete_all_segments(self, *, interrupted: bool, reason: str) -> None:
628
+ for req_id in list(self._segments.keys()):
629
+ self._complete_segment(req_id=req_id, interrupted=interrupted, reason=reason)
630
+
631
+ self._active_req_id = None
632
+ self._cancel_active_segment_idle_end()
633
+ self._pending_segment_ids.clear()
634
+
635
+ async def _handle_interrupt(self) -> None:
636
+ """Handle interruption - stop avatar's current audio processing."""
637
+ if not self._spatius_session:
638
+ return
639
+
640
+ try:
641
+ interrupted_id = await self._spatius_session.interrupt()
642
+
643
+ async with self._segment_finalize_lock:
644
+ if not self._complete_segment(req_id=interrupted_id, interrupted=True, reason="interrupt"):
645
+ # Fallback: a race can leave the request id unmatched.
646
+ if self._active_req_id is not None:
647
+ self._complete_segment(
648
+ req_id=self._active_req_id,
649
+ interrupted=True,
650
+ reason="interrupt_fallback",
651
+ )
652
+ # Complete any remaining pending segments that were also interrupted
653
+ for req_id in list(self._segments.keys()):
654
+ self._complete_segment(req_id=req_id, interrupted=True, reason="interrupt_remaining")
655
+
656
+ logger.debug(f"Avatar interrupted, request_id={interrupted_id}")
657
+ except Exception as e:
658
+ logger.warning(f"Failed to interrupt avatar: {e}")
659
+
660
+ async def aclose(self) -> None:
661
+ """Clean up avatar session resources."""
662
+ if self._main_task:
663
+ self._main_task.cancel()
664
+ try:
665
+ await self._main_task
666
+ except asyncio.CancelledError:
667
+ pass
668
+ self._main_task = None
669
+
670
+ self._cancel_active_segment_idle_end()
671
+
672
+ self._complete_all_segments(interrupted=True, reason="session_close")
673
+
674
+ if (
675
+ self._agent_session
676
+ and self._audio_buffer
677
+ and self._audio_output_attached
678
+ and self._agent_session.output.audio is self._audio_buffer
679
+ ):
680
+ self._agent_session.output.audio = self._original_audio_output
681
+ self._audio_output_attached = False
682
+ self._original_audio_output = None
683
+
684
+ if self._audio_buffer:
685
+ await self._audio_buffer.aclose()
686
+ self._audio_buffer = None
687
+
688
+ if self._spatius_session:
689
+ try:
690
+ await self._spatius_session.close()
691
+ logger.info("Avatar session closed")
692
+ except Exception as e:
693
+ logger.warning(f"Error closing avatar session: {e}")
694
+ finally:
695
+ self._spatius_session = None
696
+
697
+ self._initialized = False
698
+ self._agent_session = None
699
+ self._audio_output_attached = False
700
+ self._original_audio_output = None
@@ -0,0 +1,3 @@
1
+ import logging
2
+
3
+ logger = logging.getLogger("livekit.plugins.spatius")
@@ -0,0 +1,67 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.28.0", "hatch-vcs>=0.5.0"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "livekit-plugins-spatius"
7
+ dynamic = ["version"]
8
+ description = "Agent Framework plugin for Spatius Avatar"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10.0"
12
+ authors = [{ name = "3DRX", email = "3drxkjy@gmail.com" }]
13
+ keywords = ["voice", "ai", "realtime", "audio", "video", "livekit", "webrtc", "avatar", "spatius"]
14
+ classifiers = [
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Topic :: Multimedia :: Sound/Audio",
18
+ "Topic :: Multimedia :: Video",
19
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Programming Language :: Python :: 3.14",
26
+ "Programming Language :: Python :: 3 :: Only",
27
+ ]
28
+ dependencies = [
29
+ "livekit-agents>=1.2.9",
30
+ "spatius>=1.0.0",
31
+ ]
32
+
33
+ [project.urls]
34
+ Documentation = "https://docs.spatius.ai"
35
+ Website = "https://www.spatius.ai/"
36
+ Source = "https://github.com/spatius-ai/livekit-plugins-spatius"
37
+
38
+ [tool.hatch.version]
39
+ source = "vcs"
40
+
41
+ [tool.hatch.build]
42
+ exclude = [
43
+ "/.uv-cache",
44
+ "/.venv",
45
+ "/.pytest_cache",
46
+ "/dist",
47
+ ]
48
+
49
+ [tool.hatch.build.targets.wheel]
50
+ packages = ["livekit"]
51
+
52
+ [tool.hatch.build.targets.sdist]
53
+ include = ["/livekit"]
54
+
55
+ [tool.ruff]
56
+ target-version = "py310"
57
+ line-length = 120
58
+
59
+ [tool.ruff.lint]
60
+ select = [
61
+ "E", # pycodestyle errors
62
+ "W", # pycodestyle warnings
63
+ "F", # pyflakes
64
+ "I", # isort
65
+ "UP", # pyupgrade
66
+ "B", # flake8-bugbear
67
+ ]