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.
- livekit_plugins_spatius-1.4.5/.gitignore +216 -0
- livekit_plugins_spatius-1.4.5/LICENSE +21 -0
- livekit_plugins_spatius-1.4.5/PKG-INFO +104 -0
- livekit_plugins_spatius-1.4.5/README.md +76 -0
- livekit_plugins_spatius-1.4.5/livekit/plugins/spatius/__init__.py +45 -0
- livekit_plugins_spatius-1.4.5/livekit/plugins/spatius/avatar.py +700 -0
- livekit_plugins_spatius-1.4.5/livekit/plugins/spatius/log.py +3 -0
- livekit_plugins_spatius-1.4.5/livekit/plugins/spatius/py.typed +0 -0
- livekit_plugins_spatius-1.4.5/pyproject.toml +67 -0
|
@@ -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
|
|
File without changes
|
|
@@ -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
|
+
]
|