livekit-plugins-synthesia 1.8.2__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_synthesia-1.8.2/.gitignore +180 -0
- livekit_plugins_synthesia-1.8.2/PKG-INFO +80 -0
- livekit_plugins_synthesia-1.8.2/README.md +56 -0
- livekit_plugins_synthesia-1.8.2/livekit/plugins/synthesia/__init__.py +52 -0
- livekit_plugins_synthesia-1.8.2/livekit/plugins/synthesia/api.py +275 -0
- livekit_plugins_synthesia-1.8.2/livekit/plugins/synthesia/avatar.py +405 -0
- livekit_plugins_synthesia-1.8.2/livekit/plugins/synthesia/errors.py +133 -0
- livekit_plugins_synthesia-1.8.2/livekit/plugins/synthesia/log.py +17 -0
- livekit_plugins_synthesia-1.8.2/livekit/plugins/synthesia/py.typed +0 -0
- livekit_plugins_synthesia-1.8.2/livekit/plugins/synthesia/types.py +72 -0
- livekit_plugins_synthesia-1.8.2/livekit/plugins/synthesia/version.py +15 -0
- livekit_plugins_synthesia-1.8.2/pyproject.toml +44 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
**/.vscode
|
|
2
|
+
**/.DS_Store
|
|
3
|
+
.env
|
|
4
|
+
|
|
5
|
+
# Byte-compiled / optimized / DLL files
|
|
6
|
+
__pycache__/
|
|
7
|
+
*.py[cod]
|
|
8
|
+
*$py.class
|
|
9
|
+
|
|
10
|
+
# C extensions
|
|
11
|
+
*.so
|
|
12
|
+
|
|
13
|
+
# Distribution / packaging
|
|
14
|
+
.Python
|
|
15
|
+
build/
|
|
16
|
+
develop-eggs/
|
|
17
|
+
dist/
|
|
18
|
+
downloads/
|
|
19
|
+
eggs/
|
|
20
|
+
.eggs/
|
|
21
|
+
lib/
|
|
22
|
+
lib64/
|
|
23
|
+
parts/
|
|
24
|
+
sdist/
|
|
25
|
+
var/
|
|
26
|
+
wheels/
|
|
27
|
+
share/python-wheels/
|
|
28
|
+
*.egg-info/
|
|
29
|
+
.installed.cfg
|
|
30
|
+
*.egg
|
|
31
|
+
MANIFEST
|
|
32
|
+
|
|
33
|
+
# PyInstaller
|
|
34
|
+
# Usually these files are written by a python script from a template
|
|
35
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
36
|
+
*.manifest
|
|
37
|
+
*.spec
|
|
38
|
+
|
|
39
|
+
# Installer logs
|
|
40
|
+
pip-log.txt
|
|
41
|
+
pip-delete-this-directory.txt
|
|
42
|
+
|
|
43
|
+
# Unit test / coverage reports
|
|
44
|
+
htmlcov/
|
|
45
|
+
.tox/
|
|
46
|
+
.nox/
|
|
47
|
+
.coverage
|
|
48
|
+
.coverage.*
|
|
49
|
+
.cache
|
|
50
|
+
nosetests.xml
|
|
51
|
+
coverage.xml
|
|
52
|
+
*.cover
|
|
53
|
+
*.py,cover
|
|
54
|
+
.hypothesis/
|
|
55
|
+
.pytest_cache/
|
|
56
|
+
cover/
|
|
57
|
+
|
|
58
|
+
# Translations
|
|
59
|
+
*.mo
|
|
60
|
+
*.pot
|
|
61
|
+
|
|
62
|
+
# Django stuff:
|
|
63
|
+
*.log
|
|
64
|
+
local_settings.py
|
|
65
|
+
db.sqlite3
|
|
66
|
+
db.sqlite3-journal
|
|
67
|
+
|
|
68
|
+
# Flask stuff:
|
|
69
|
+
instance/
|
|
70
|
+
.webassets-cache
|
|
71
|
+
|
|
72
|
+
# Scrapy stuff:
|
|
73
|
+
.scrapy
|
|
74
|
+
|
|
75
|
+
# Sphinx documentation
|
|
76
|
+
docs/_build/
|
|
77
|
+
|
|
78
|
+
# PyBuilder
|
|
79
|
+
.pybuilder/
|
|
80
|
+
target/
|
|
81
|
+
|
|
82
|
+
# Jupyter Notebook
|
|
83
|
+
.ipynb_checkpoints
|
|
84
|
+
|
|
85
|
+
# IPython
|
|
86
|
+
profile_default/
|
|
87
|
+
ipython_config.py
|
|
88
|
+
|
|
89
|
+
# pyenv
|
|
90
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
91
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
92
|
+
# .python-version
|
|
93
|
+
|
|
94
|
+
# pipenv
|
|
95
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
96
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
97
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
98
|
+
# install all needed dependencies.
|
|
99
|
+
#Pipfile.lock
|
|
100
|
+
|
|
101
|
+
# poetry
|
|
102
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
103
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
104
|
+
# commonly ignored for libraries.
|
|
105
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
106
|
+
#poetry.lock
|
|
107
|
+
|
|
108
|
+
# pdm
|
|
109
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
110
|
+
#pdm.lock
|
|
111
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
112
|
+
# in version control.
|
|
113
|
+
# https://pdm.fming.dev/#use-with-ide
|
|
114
|
+
.pdm.toml
|
|
115
|
+
|
|
116
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
117
|
+
__pypackages__/
|
|
118
|
+
|
|
119
|
+
# Celery stuff
|
|
120
|
+
celerybeat-schedule
|
|
121
|
+
celerybeat.pid
|
|
122
|
+
|
|
123
|
+
# SageMath parsed files
|
|
124
|
+
*.sage.py
|
|
125
|
+
|
|
126
|
+
# Environments
|
|
127
|
+
.env
|
|
128
|
+
.venv
|
|
129
|
+
env/
|
|
130
|
+
venv/
|
|
131
|
+
ENV/
|
|
132
|
+
env.bak/
|
|
133
|
+
venv.bak/
|
|
134
|
+
|
|
135
|
+
# Spyder project settings
|
|
136
|
+
.spyderproject
|
|
137
|
+
.spyproject
|
|
138
|
+
|
|
139
|
+
# Rope project settings
|
|
140
|
+
.ropeproject
|
|
141
|
+
|
|
142
|
+
# mkdocs documentation
|
|
143
|
+
/site
|
|
144
|
+
|
|
145
|
+
# mypy
|
|
146
|
+
.mypy_cache/
|
|
147
|
+
.dmypy.json
|
|
148
|
+
dmypy.json
|
|
149
|
+
|
|
150
|
+
# trunk
|
|
151
|
+
.trunk/
|
|
152
|
+
|
|
153
|
+
# Pyre type checker
|
|
154
|
+
.pyre/
|
|
155
|
+
|
|
156
|
+
# pytype static type analyzer
|
|
157
|
+
.pytype/
|
|
158
|
+
|
|
159
|
+
# Cython debug symbols
|
|
160
|
+
cython_debug/
|
|
161
|
+
|
|
162
|
+
# PyCharm
|
|
163
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
164
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
165
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
166
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
167
|
+
.idea/
|
|
168
|
+
|
|
169
|
+
node_modules
|
|
170
|
+
|
|
171
|
+
credentials.json
|
|
172
|
+
pyrightconfig.json
|
|
173
|
+
docs/
|
|
174
|
+
|
|
175
|
+
# Database files
|
|
176
|
+
*.db
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# Examples for development
|
|
180
|
+
examples/dev/*
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: livekit-plugins-synthesia
|
|
3
|
+
Version: 1.8.2
|
|
4
|
+
Summary: LiveKit Agents plugin for Synthesia interactive avatars
|
|
5
|
+
Project-URL: Documentation, https://docs.livekit.io
|
|
6
|
+
Project-URL: Website, https://livekit.io/
|
|
7
|
+
Project-URL: Source, https://github.com/livekit/agents
|
|
8
|
+
Author-email: LiveKit <support@livekit.io>
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
Keywords: ai,audio,avatar,livekit,realtime,synthesia,video,voice,webrtc
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
19
|
+
Classifier: Topic :: Multimedia :: Video
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
21
|
+
Requires-Python: >=3.10.0
|
|
22
|
+
Requires-Dist: livekit-agents>=1.8.2
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# Synthesia plugin for LiveKit Agents
|
|
26
|
+
|
|
27
|
+
Attach a [Synthesia](https://www.synthesia.io/) interactive avatar to a LiveKit voice agent. The avatar joins
|
|
28
|
+
the room and lip-syncs the agent's speech in real time.
|
|
29
|
+
|
|
30
|
+
See the [Synthesia integration docs](https://docs.livekit.io/agents/models/avatar/plugins/synthesia/) for more information.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install livekit-plugins-synthesia
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Pre-requisites
|
|
39
|
+
|
|
40
|
+
You'll need an API key from Synthesia. It can be set as an environment variable:
|
|
41
|
+
`SYNTHESIA_API_KEY`
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from livekit.plugins import synthesia
|
|
47
|
+
|
|
48
|
+
avatar = synthesia.AvatarSession(
|
|
49
|
+
synthesia.AvatarConfig(
|
|
50
|
+
avatar_ids=["03cee7ec-ac90-45ec-8c20-74a399cf3dc4"]
|
|
51
|
+
), # SYNTHESIA_API_KEY from env
|
|
52
|
+
)
|
|
53
|
+
await avatar.start(session, room=ctx.room) # before session.start
|
|
54
|
+
await session.start(agent=Agent(...), room=ctx.room)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Set your Synthesia workspace API key in `SYNTHESIA_API_KEY`, or pass `api_key=`.
|
|
58
|
+
`avatar_ids` takes one to five gallery ids of avatars available to your
|
|
59
|
+
workspace. The first is the active avatar; the rest are precomputed by the
|
|
60
|
+
worker so `swap_avatar()` can switch to them mid-session. An id your workspace
|
|
61
|
+
cannot access raises `synthesia.SynthesiaError` with `type=synthesia.ErrorType.UNKNOWN_AVATAR`.
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
await avatar.swap_avatar("<another-id-from-avatar-ids>") # switch mid-session
|
|
65
|
+
await avatar.swap_avatar("default") # back to the first id
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Parameters
|
|
69
|
+
|
|
70
|
+
| Parameter | Default | Description |
|
|
71
|
+
| --- | --- | --- |
|
|
72
|
+
| `avatar_participant_identity` | `"synthesia-avatar-agent"` | The LiveKit identity the avatar joins under. Must be unique per concurrent avatar in a room: LiveKit evicts an existing participant when a second joins with the same identity, so give each avatar its own identity to run several in one room. |
|
|
73
|
+
| `avatar_participant_name` | `"Synthesia avatar"` | The LiveKit display name the avatar joins under. |
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
avatar = synthesia.AvatarSession(
|
|
77
|
+
synthesia.AvatarConfig(avatar_ids=["03cee7ec-ac90-45ec-8c20-74a399cf3dc4"]),
|
|
78
|
+
avatar_participant_identity="avatar-host",
|
|
79
|
+
)
|
|
80
|
+
```
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Synthesia plugin for LiveKit Agents
|
|
2
|
+
|
|
3
|
+
Attach a [Synthesia](https://www.synthesia.io/) interactive avatar to a LiveKit voice agent. The avatar joins
|
|
4
|
+
the room and lip-syncs the agent's speech in real time.
|
|
5
|
+
|
|
6
|
+
See the [Synthesia integration docs](https://docs.livekit.io/agents/models/avatar/plugins/synthesia/) for more information.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install livekit-plugins-synthesia
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Pre-requisites
|
|
15
|
+
|
|
16
|
+
You'll need an API key from Synthesia. It can be set as an environment variable:
|
|
17
|
+
`SYNTHESIA_API_KEY`
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from livekit.plugins import synthesia
|
|
23
|
+
|
|
24
|
+
avatar = synthesia.AvatarSession(
|
|
25
|
+
synthesia.AvatarConfig(
|
|
26
|
+
avatar_ids=["03cee7ec-ac90-45ec-8c20-74a399cf3dc4"]
|
|
27
|
+
), # SYNTHESIA_API_KEY from env
|
|
28
|
+
)
|
|
29
|
+
await avatar.start(session, room=ctx.room) # before session.start
|
|
30
|
+
await session.start(agent=Agent(...), room=ctx.room)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Set your Synthesia workspace API key in `SYNTHESIA_API_KEY`, or pass `api_key=`.
|
|
34
|
+
`avatar_ids` takes one to five gallery ids of avatars available to your
|
|
35
|
+
workspace. The first is the active avatar; the rest are precomputed by the
|
|
36
|
+
worker so `swap_avatar()` can switch to them mid-session. An id your workspace
|
|
37
|
+
cannot access raises `synthesia.SynthesiaError` with `type=synthesia.ErrorType.UNKNOWN_AVATAR`.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
await avatar.swap_avatar("<another-id-from-avatar-ids>") # switch mid-session
|
|
41
|
+
await avatar.swap_avatar("default") # back to the first id
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Parameters
|
|
45
|
+
|
|
46
|
+
| Parameter | Default | Description |
|
|
47
|
+
| --- | --- | --- |
|
|
48
|
+
| `avatar_participant_identity` | `"synthesia-avatar-agent"` | The LiveKit identity the avatar joins under. Must be unique per concurrent avatar in a room: LiveKit evicts an existing participant when a second joins with the same identity, so give each avatar its own identity to run several in one room. |
|
|
49
|
+
| `avatar_participant_name` | `"Synthesia avatar"` | The LiveKit display name the avatar joins under. |
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
avatar = synthesia.AvatarSession(
|
|
53
|
+
synthesia.AvatarConfig(avatar_ids=["03cee7ec-ac90-45ec-8c20-74a399cf3dc4"]),
|
|
54
|
+
avatar_participant_identity="avatar-host",
|
|
55
|
+
)
|
|
56
|
+
```
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Copyright 2026 LiveKit, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Synthesia plugin for LiveKit Agents
|
|
16
|
+
|
|
17
|
+
See https://docs.livekit.io/agents/models/avatar/plugins/synthesia/ for more information.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from .avatar import AvatarSession
|
|
21
|
+
from .errors import ErrorType, SynthesiaError
|
|
22
|
+
from .types import AvatarConfig
|
|
23
|
+
from .version import __version__
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"AvatarSession",
|
|
27
|
+
"AvatarConfig",
|
|
28
|
+
"SynthesiaError",
|
|
29
|
+
"ErrorType",
|
|
30
|
+
"__version__",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
from livekit.agents import Plugin
|
|
34
|
+
|
|
35
|
+
from .log import logger
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SynthesiaPlugin(Plugin):
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
super().__init__(__name__, __version__, __package__, logger)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
Plugin.register_plugin(SynthesiaPlugin())
|
|
44
|
+
|
|
45
|
+
# Cleanup docs of unexported modules
|
|
46
|
+
_module = dir()
|
|
47
|
+
NOT_IN_ALL = [m for m in _module if m not in __all__]
|
|
48
|
+
|
|
49
|
+
__pdoc__ = {}
|
|
50
|
+
|
|
51
|
+
for n in NOT_IN_ALL:
|
|
52
|
+
__pdoc__[n] = False
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# Copyright 2026 LiveKit, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import json
|
|
19
|
+
|
|
20
|
+
import aiohttp
|
|
21
|
+
|
|
22
|
+
from livekit.agents import DEFAULT_API_CONNECT_OPTIONS, APIConnectOptions, utils
|
|
23
|
+
|
|
24
|
+
from .errors import ErrorType, SynthesiaError
|
|
25
|
+
from .log import logger
|
|
26
|
+
from .types import StartSessionRequest, StartSessionResponse
|
|
27
|
+
|
|
28
|
+
SESSION_PATH = "/api/interactive-avatars/sessions"
|
|
29
|
+
_AVATAR_ID_PREFIX = "av_"
|
|
30
|
+
|
|
31
|
+
# Machine-readable error codes returned by the backend in the response body.
|
|
32
|
+
_CODE_TO_ERROR: dict[str, ErrorType] = {
|
|
33
|
+
"unauthorized": ErrorType.AUTH,
|
|
34
|
+
"invalid_api_key": ErrorType.AUTH,
|
|
35
|
+
"unknown_avatar": ErrorType.UNKNOWN_AVATAR,
|
|
36
|
+
"avatar_not_accessible": ErrorType.UNKNOWN_AVATAR,
|
|
37
|
+
"quota_exceeded": ErrorType.QUOTA_EXCEEDED,
|
|
38
|
+
"rate_limited": ErrorType.RATE_LIMITED,
|
|
39
|
+
"invalid_token": ErrorType.INVALID_ROOM_TOKEN,
|
|
40
|
+
"invalid_livekit_credentials": ErrorType.LIVEKIT_CREDENTIALS_REJECTED,
|
|
41
|
+
"validation_error": ErrorType.INVALID_SESSION_REQUEST,
|
|
42
|
+
"bad_request": ErrorType.INVALID_SESSION_REQUEST,
|
|
43
|
+
"unknown_reference": ErrorType.UNKNOWN_AVATAR,
|
|
44
|
+
"unauthenticated": ErrorType.AUTH,
|
|
45
|
+
"forbidden": ErrorType.AUTH,
|
|
46
|
+
"insufficient_scope": ErrorType.AUTH,
|
|
47
|
+
"not_authorized": ErrorType.AUTH,
|
|
48
|
+
"feature_not_in_plan": ErrorType.FEATURE_NOT_IN_PLAN,
|
|
49
|
+
"payment_required": ErrorType.QUOTA_EXCEEDED,
|
|
50
|
+
"concurrency_limit": ErrorType.CONCURRENCY_LIMIT,
|
|
51
|
+
# Production still emits this until the taxonomy rename reaches prod. Drop
|
|
52
|
+
# once the API only sends "concurrency_limit".
|
|
53
|
+
"concurrency_limit_exceeded": ErrorType.CONCURRENCY_LIMIT,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Fallback when the body carries no code. A bare 401 is ambiguous
|
|
57
|
+
# between the two credentials in play, so it stays the broader auth error.
|
|
58
|
+
_STATUS_TO_ERROR: dict[int, ErrorType] = {
|
|
59
|
+
401: ErrorType.AUTH,
|
|
60
|
+
403: ErrorType.AUTH,
|
|
61
|
+
402: ErrorType.QUOTA_EXCEEDED,
|
|
62
|
+
404: ErrorType.UNKNOWN_AVATAR,
|
|
63
|
+
429: ErrorType.RATE_LIMITED,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SynthesiaAPI:
|
|
68
|
+
"""Async client for the Synthesia interactive-avatar session API.
|
|
69
|
+
|
|
70
|
+
Opens one session per ``start_session`` call. Pass ``session`` to reuse an
|
|
71
|
+
``aiohttp.ClientSession``; when omitted the LiveKit Agents shared session is
|
|
72
|
+
used.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
api_key: str,
|
|
79
|
+
api_url: str,
|
|
80
|
+
session: aiohttp.ClientSession | None = None,
|
|
81
|
+
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
|
|
82
|
+
) -> None:
|
|
83
|
+
self._api_key = api_key
|
|
84
|
+
self._api_url = api_url.rstrip("/")
|
|
85
|
+
self._session = session
|
|
86
|
+
self._conn_options = conn_options
|
|
87
|
+
|
|
88
|
+
def _http(self) -> aiohttp.ClientSession:
|
|
89
|
+
return self._session or utils.http_context.http_session()
|
|
90
|
+
|
|
91
|
+
async def start_session(
|
|
92
|
+
self,
|
|
93
|
+
request: StartSessionRequest,
|
|
94
|
+
*,
|
|
95
|
+
conn_options: APIConnectOptions | None = None,
|
|
96
|
+
) -> StartSessionResponse:
|
|
97
|
+
conn_options = conn_options or self._conn_options
|
|
98
|
+
url = self._api_url + SESSION_PATH
|
|
99
|
+
headers = {"Authorization": self._api_key}
|
|
100
|
+
payload: dict[str, object] = {
|
|
101
|
+
"avatarIds": [_public_avatar_id(a) for a in request.avatar_ids],
|
|
102
|
+
"livekitUrl": request.livekit_url,
|
|
103
|
+
"livekitToken": request.lk_token,
|
|
104
|
+
}
|
|
105
|
+
timeout = aiohttp.ClientTimeout(total=conn_options.timeout)
|
|
106
|
+
|
|
107
|
+
last_status: int | None = None
|
|
108
|
+
last_body: object = None
|
|
109
|
+
last_exc: Exception | None = None
|
|
110
|
+
|
|
111
|
+
for attempt in range(conn_options.max_retry + 1):
|
|
112
|
+
status: int | None = None
|
|
113
|
+
try:
|
|
114
|
+
async with self._http().post(
|
|
115
|
+
url, headers=headers, json=payload, timeout=timeout
|
|
116
|
+
) as resp:
|
|
117
|
+
status = resp.status
|
|
118
|
+
body = await _read_json(resp)
|
|
119
|
+
if resp.ok:
|
|
120
|
+
return self._parse_success(body)
|
|
121
|
+
if status < 500:
|
|
122
|
+
raise self._mapped_error(resp, body)
|
|
123
|
+
last_status, last_body, last_exc = status, body, None
|
|
124
|
+
logger.debug(
|
|
125
|
+
"synthesia session request failed, retrying",
|
|
126
|
+
extra={"status": status},
|
|
127
|
+
)
|
|
128
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
|
|
129
|
+
# A 5xx whose body fails to arrive is still a 5xx, not a transport failure.
|
|
130
|
+
last_status = status if status is not None and status >= 500 else None
|
|
131
|
+
last_body, last_exc = None, e
|
|
132
|
+
logger.debug(
|
|
133
|
+
"synthesia session request errored, retrying",
|
|
134
|
+
extra={"error": type(e).__name__},
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
if attempt < conn_options.max_retry:
|
|
138
|
+
await asyncio.sleep(conn_options.retry_interval)
|
|
139
|
+
|
|
140
|
+
raise SynthesiaError(
|
|
141
|
+
_exhausted_message(conn_options.max_retry + 1, last_status, last_body),
|
|
142
|
+
type=ErrorType.CONNECTION,
|
|
143
|
+
body=last_body,
|
|
144
|
+
status=last_status,
|
|
145
|
+
request_id=_body_request_id(last_body),
|
|
146
|
+
) from last_exc
|
|
147
|
+
|
|
148
|
+
def _parse_success(self, body: object) -> StartSessionResponse:
|
|
149
|
+
if isinstance(body, dict):
|
|
150
|
+
session_id = body.get("id") or body.get("session_id")
|
|
151
|
+
if isinstance(session_id, str) and session_id:
|
|
152
|
+
return StartSessionResponse(session_id=session_id)
|
|
153
|
+
raise SynthesiaError("Synthesia response did not contain a session id")
|
|
154
|
+
|
|
155
|
+
def _mapped_error(self, resp: aiohttp.ClientResponse, body: object) -> SynthesiaError:
|
|
156
|
+
code = _body_code(body)
|
|
157
|
+
error_type = _CODE_TO_ERROR.get(code or "")
|
|
158
|
+
if error_type is None:
|
|
159
|
+
# A problem body names its code; an unknown one is a new code, not a hint to
|
|
160
|
+
# guess the type from the status.
|
|
161
|
+
error_type = (
|
|
162
|
+
None
|
|
163
|
+
if _is_problem(body) and code is not None
|
|
164
|
+
else _STATUS_TO_ERROR.get(resp.status)
|
|
165
|
+
)
|
|
166
|
+
message = _body_message(body) or (
|
|
167
|
+
f"Synthesia request failed ({code or f'HTTP {resp.status}'})"
|
|
168
|
+
)
|
|
169
|
+
request_id = _body_request_id(body)
|
|
170
|
+
retry_after = (
|
|
171
|
+
_parse_retry_after(resp, body)
|
|
172
|
+
if error_type in (ErrorType.RATE_LIMITED, ErrorType.CONCURRENCY_LIMIT)
|
|
173
|
+
else None
|
|
174
|
+
)
|
|
175
|
+
return SynthesiaError(
|
|
176
|
+
message,
|
|
177
|
+
type=error_type,
|
|
178
|
+
retry_after=retry_after,
|
|
179
|
+
body=body,
|
|
180
|
+
status=resp.status,
|
|
181
|
+
request_id=request_id,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _exhausted_message(attempts: int, status: int | None, body: object) -> str:
|
|
186
|
+
message = "could not start a Synthesia session"
|
|
187
|
+
if attempts > 1:
|
|
188
|
+
message = f"{message} after {attempts} attempts"
|
|
189
|
+
if status is None:
|
|
190
|
+
return f"{message}; last attempt: connection error"
|
|
191
|
+
message = f"{message}; last attempt: HTTP {status}"
|
|
192
|
+
detail = _body_message(body)
|
|
193
|
+
if detail is not None:
|
|
194
|
+
message = f"{message}: {detail}"
|
|
195
|
+
request_id = _body_request_id(body)
|
|
196
|
+
if request_id is not None:
|
|
197
|
+
message = f"{message} (request {request_id})"
|
|
198
|
+
return message
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _is_problem(body: object) -> bool:
|
|
202
|
+
return isinstance(body, dict) and isinstance(body.get("type"), str)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _body_code(body: object) -> str | None:
|
|
206
|
+
if not isinstance(body, dict):
|
|
207
|
+
return None
|
|
208
|
+
err = body.get("error")
|
|
209
|
+
code = err.get("code") if isinstance(err, dict) else None
|
|
210
|
+
code = code or body.get("code")
|
|
211
|
+
return code if isinstance(code, str) else None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _body_message(body: object) -> str | None:
|
|
215
|
+
if not isinstance(body, dict):
|
|
216
|
+
return None
|
|
217
|
+
if _is_problem(body):
|
|
218
|
+
candidates = (body.get("detail"), body.get("title"))
|
|
219
|
+
return next((c for c in candidates if isinstance(c, str) and c), None)
|
|
220
|
+
err = body.get("error")
|
|
221
|
+
message = err.get("message") if isinstance(err, dict) else None
|
|
222
|
+
# Some responses use a flat shape, e.g.
|
|
223
|
+
# {"error": "Forbidden", "context": "User is not authenticated"}.
|
|
224
|
+
detail = message or body.get("message") or body.get("context")
|
|
225
|
+
if detail is None:
|
|
226
|
+
return err if isinstance(err, str) else None
|
|
227
|
+
if isinstance(detail, str):
|
|
228
|
+
return detail
|
|
229
|
+
detail = json.dumps(detail, separators=(", ", ": "))
|
|
230
|
+
code = _body_code(body)
|
|
231
|
+
return f"{code}: {detail}" if code else detail
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _body_request_id(body: object) -> str | None:
|
|
235
|
+
if isinstance(body, dict):
|
|
236
|
+
request_id = body.get("requestId")
|
|
237
|
+
if isinstance(request_id, str):
|
|
238
|
+
return request_id
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _public_avatar_id(avatar_id: str) -> str:
|
|
243
|
+
"""Prefix a gallery id for the wire, leaving an already-prefixed id alone.
|
|
244
|
+
|
|
245
|
+
Callers pass the raw gallery id they see in Synthesia, and the API takes the
|
|
246
|
+
namespaced form.
|
|
247
|
+
"""
|
|
248
|
+
return avatar_id if avatar_id.startswith(_AVATAR_ID_PREFIX) else _AVATAR_ID_PREFIX + avatar_id
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _parse_retry_after(resp: aiohttp.ClientResponse, body: object) -> float | None:
|
|
252
|
+
header = resp.headers.get("Retry-After")
|
|
253
|
+
if header is not None:
|
|
254
|
+
try:
|
|
255
|
+
return float(header)
|
|
256
|
+
except ValueError:
|
|
257
|
+
pass
|
|
258
|
+
if isinstance(body, dict):
|
|
259
|
+
candidates = [body.get("retry_after")]
|
|
260
|
+
err = body.get("error")
|
|
261
|
+
if isinstance(err, dict):
|
|
262
|
+
candidates.append(err.get("retry_after"))
|
|
263
|
+
for value in candidates:
|
|
264
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
265
|
+
return float(value)
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
async def _read_json(resp: aiohttp.ClientResponse) -> object:
|
|
270
|
+
# Errors arrive as application/problem+json, which aiohttp refuses by default,
|
|
271
|
+
# so the content type is checked here rather than delegated.
|
|
272
|
+
try:
|
|
273
|
+
return await resp.json(content_type=resp.content_type)
|
|
274
|
+
except (aiohttp.ContentTypeError, ValueError):
|
|
275
|
+
return None
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
# Copyright 2026 LiveKit, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import dataclasses
|
|
19
|
+
import enum
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
from typing import TypeGuard
|
|
23
|
+
|
|
24
|
+
from livekit import api, rtc
|
|
25
|
+
from livekit.agents import DEFAULT_API_CONNECT_OPTIONS, AgentSession, get_job_context
|
|
26
|
+
from livekit.agents.types import ATTRIBUTE_PUBLISH_ON_BEHALF
|
|
27
|
+
from livekit.agents.voice.avatar import AvatarSession as BaseAvatarSession, DataStreamAudioOutput
|
|
28
|
+
|
|
29
|
+
from .api import SynthesiaAPI
|
|
30
|
+
from .errors import ErrorType, SynthesiaError
|
|
31
|
+
from .log import logger
|
|
32
|
+
from .types import (
|
|
33
|
+
AVATAR_IDENTITY,
|
|
34
|
+
AVATAR_NAME,
|
|
35
|
+
DEFAULT_API_URL,
|
|
36
|
+
DEFAULT_JOIN_TIMEOUT,
|
|
37
|
+
DEFAULT_SWAP_TIMEOUT,
|
|
38
|
+
TOKEN_TTL,
|
|
39
|
+
AvatarConfig,
|
|
40
|
+
SessionConfig,
|
|
41
|
+
StartSessionRequest,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _present(value: str | None) -> TypeGuard[str]:
|
|
46
|
+
return bool(value and value.strip())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _to_ws_url(url: str) -> str:
|
|
50
|
+
"""Coerce a LiveKit URL to the ws(s):// scheme Synthesia requires; LiveKit
|
|
51
|
+
Cloud injects https:// on deployed agents."""
|
|
52
|
+
if url.startswith("https://"):
|
|
53
|
+
return "wss://" + url[len("https://") :]
|
|
54
|
+
if url.startswith("http://"):
|
|
55
|
+
return "ws://" + url[len("http://") :]
|
|
56
|
+
return url
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _State(enum.Enum):
|
|
60
|
+
IDLE = "idle"
|
|
61
|
+
STARTED = "started"
|
|
62
|
+
CLOSED = "closed"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class AvatarSession(BaseAvatarSession):
|
|
66
|
+
"""A Synthesia interactive avatar for a LiveKit voice agent.
|
|
67
|
+
|
|
68
|
+
Construct with up to five gallery ``avatar_ids`` and call ``start()`` before
|
|
69
|
+
``AgentSession.start()``. The first id is the active avatar and the rest are
|
|
70
|
+
precomputed for a future mid-session swap. Credentials fall back to the
|
|
71
|
+
``SYNTHESIA_API_KEY`` and ``SYNTHESIA_API_URL`` environment variables.
|
|
72
|
+
|
|
73
|
+
Logs when the room ends cleanly and when the avatar track drops
|
|
74
|
+
unexpectedly mid-session.
|
|
75
|
+
|
|
76
|
+
Pass ``avatar_participant_identity`` to override the LiveKit identity the
|
|
77
|
+
avatar joins under. It must be unique per concurrent avatar in a room, since
|
|
78
|
+
LiveKit evicts an existing participant when a second joins with the same
|
|
79
|
+
identity. It defaults to ``synthesia-avatar-agent``.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
avatar_config: AvatarConfig,
|
|
85
|
+
*,
|
|
86
|
+
api_key: str | None = None,
|
|
87
|
+
api_url: str | None = None,
|
|
88
|
+
join_timeout: float = DEFAULT_JOIN_TIMEOUT,
|
|
89
|
+
avatar_participant_identity: str | None = None,
|
|
90
|
+
avatar_participant_name: str | None = None,
|
|
91
|
+
) -> None:
|
|
92
|
+
super().__init__()
|
|
93
|
+
|
|
94
|
+
if avatar_participant_identity is not None and not avatar_participant_identity.strip():
|
|
95
|
+
raise SynthesiaError("avatar_participant_identity must be a non-empty string")
|
|
96
|
+
if avatar_participant_name is not None and not avatar_participant_name.strip():
|
|
97
|
+
raise SynthesiaError("avatar_participant_name must be a non-empty string")
|
|
98
|
+
self._avatar_identity = avatar_participant_identity or AVATAR_IDENTITY
|
|
99
|
+
self._avatar_name = avatar_participant_name or AVATAR_NAME
|
|
100
|
+
|
|
101
|
+
key = api_key or os.environ.get("SYNTHESIA_API_KEY")
|
|
102
|
+
if not key:
|
|
103
|
+
raise SynthesiaError(
|
|
104
|
+
"a Synthesia API key is required: pass api_key or set SYNTHESIA_API_KEY"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
url = api_url or os.environ.get("SYNTHESIA_API_URL") or DEFAULT_API_URL
|
|
108
|
+
|
|
109
|
+
self._config = SessionConfig(
|
|
110
|
+
avatar_ids=tuple(avatar_config.avatar_ids),
|
|
111
|
+
api_key=key,
|
|
112
|
+
api_url=url,
|
|
113
|
+
join_timeout=join_timeout,
|
|
114
|
+
)
|
|
115
|
+
self._state = _State.IDLE
|
|
116
|
+
# Guards against re-entrant start() calls. Distinct from _state: while a
|
|
117
|
+
# start is awaiting a prior teardown the lifecycle is still its old
|
|
118
|
+
# state, so this cannot be folded into _state.
|
|
119
|
+
self._starting = False
|
|
120
|
+
self._close_done = asyncio.Event()
|
|
121
|
+
self._session_id: str | None = None
|
|
122
|
+
self._teardown_task: asyncio.Task[None] | None = None
|
|
123
|
+
# The exact sink this session installed, tracked by identity rather than
|
|
124
|
+
# type or chain position: AgentSession.start() can wrap it in
|
|
125
|
+
# TranscriptSynchronizer/RecorderAudioOutput, and a caller may already
|
|
126
|
+
# have their own DataStreamAudioOutput installed before start() runs.
|
|
127
|
+
self._audio_output: DataStreamAudioOutput | None = None
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def avatar_identity(self) -> str:
|
|
131
|
+
return self._avatar_identity
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def provider(self) -> str:
|
|
135
|
+
return "synthesia"
|
|
136
|
+
|
|
137
|
+
async def start(
|
|
138
|
+
self,
|
|
139
|
+
agent_session: AgentSession,
|
|
140
|
+
room: rtc.Room,
|
|
141
|
+
*,
|
|
142
|
+
livekit_url: str | None = None,
|
|
143
|
+
livekit_api_key: str | None = None,
|
|
144
|
+
livekit_api_secret: str | None = None,
|
|
145
|
+
) -> None:
|
|
146
|
+
if self._starting:
|
|
147
|
+
raise SynthesiaError("start() is already in progress")
|
|
148
|
+
self._starting = True
|
|
149
|
+
try:
|
|
150
|
+
if self._teardown_task is not None:
|
|
151
|
+
teardown = self._teardown_task
|
|
152
|
+
await asyncio.shield(asyncio.gather(teardown, return_exceptions=True))
|
|
153
|
+
if not teardown.cancelled() and teardown.exception() is not None:
|
|
154
|
+
raise SynthesiaError(
|
|
155
|
+
"the previous avatar session did not tear down cleanly; not restarting"
|
|
156
|
+
) from teardown.exception()
|
|
157
|
+
if self._state is _State.STARTED:
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
url = livekit_url or os.environ.get("LIVEKIT_URL")
|
|
161
|
+
lk_key = livekit_api_key or os.environ.get("LIVEKIT_API_KEY")
|
|
162
|
+
lk_secret = livekit_api_secret or os.environ.get("LIVEKIT_API_SECRET")
|
|
163
|
+
# A blank key or secret still mints a syntactically valid token that
|
|
164
|
+
# Synthesia accepts, since it cannot verify a signature made with the
|
|
165
|
+
# developer's own secret. LiveKit only rejects it once the avatar
|
|
166
|
+
# tries to join, so catch it here instead.
|
|
167
|
+
if not _present(url) or not _present(lk_key) or not _present(lk_secret):
|
|
168
|
+
raise SynthesiaError(
|
|
169
|
+
"LiveKit url, API key, and API secret are required: pass them or "
|
|
170
|
+
"set LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET"
|
|
171
|
+
)
|
|
172
|
+
url = _to_ws_url(url)
|
|
173
|
+
if not url.startswith(("ws://", "wss://")):
|
|
174
|
+
raise SynthesiaError(f"livekit_url {url!r} is not a ws:// or wss:// URL")
|
|
175
|
+
|
|
176
|
+
self._state = _State.IDLE
|
|
177
|
+
self._close_done.clear()
|
|
178
|
+
self._teardown_task = None
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
await super().start(agent_session, room)
|
|
182
|
+
token = self._mint_token(room=room, lk_key=lk_key, lk_secret=lk_secret)
|
|
183
|
+
client = SynthesiaAPI(api_key=self._config.api_key, api_url=self._config.api_url)
|
|
184
|
+
launch_conn = dataclasses.replace(
|
|
185
|
+
DEFAULT_API_CONNECT_OPTIONS,
|
|
186
|
+
timeout=self._config.join_timeout,
|
|
187
|
+
max_retry=0,
|
|
188
|
+
)
|
|
189
|
+
response = await client.start_session(
|
|
190
|
+
StartSessionRequest(
|
|
191
|
+
avatar_ids=list(self._config.avatar_ids),
|
|
192
|
+
livekit_url=url,
|
|
193
|
+
lk_token=token,
|
|
194
|
+
),
|
|
195
|
+
conn_options=launch_conn,
|
|
196
|
+
)
|
|
197
|
+
self._session_id = response.session_id
|
|
198
|
+
|
|
199
|
+
# TODO: confirm the avatar worker's expected audio sample rate and
|
|
200
|
+
# pass sample_rate explicitly once it is verified against the real
|
|
201
|
+
# worker.
|
|
202
|
+
audio_output = DataStreamAudioOutput(
|
|
203
|
+
room,
|
|
204
|
+
destination_identity=self.avatar_identity,
|
|
205
|
+
wait_remote_track=rtc.TrackKind.KIND_VIDEO,
|
|
206
|
+
)
|
|
207
|
+
# replace_audio_tail keeps any wrapper AgentSession.start() adds
|
|
208
|
+
# later (TranscriptSynchronizer, RecorderAudioOutput) attached.
|
|
209
|
+
agent_session.output.replace_audio_tail(audio_output)
|
|
210
|
+
self._audio_output = audio_output
|
|
211
|
+
|
|
212
|
+
await self.wait_for_join(timeout=self._config.join_timeout)
|
|
213
|
+
except asyncio.TimeoutError as e:
|
|
214
|
+
await self.aclose()
|
|
215
|
+
await self._discard_partial_start()
|
|
216
|
+
raise SynthesiaError(
|
|
217
|
+
f"avatar did not join within {self._config.join_timeout}s",
|
|
218
|
+
type=ErrorType.TIMEOUT,
|
|
219
|
+
) from e
|
|
220
|
+
except BaseException:
|
|
221
|
+
await self.aclose()
|
|
222
|
+
await self._discard_partial_start()
|
|
223
|
+
raise
|
|
224
|
+
|
|
225
|
+
if self._state is _State.CLOSED:
|
|
226
|
+
await self._close_done.wait()
|
|
227
|
+
await self._discard_partial_start()
|
|
228
|
+
raise SynthesiaError("avatar session was closed while starting")
|
|
229
|
+
|
|
230
|
+
room.on("disconnected", self._on_room_disconnected)
|
|
231
|
+
room.on("track_unpublished", self._on_track_unpublished)
|
|
232
|
+
room.on("participant_disconnected", self._on_participant_disconnected)
|
|
233
|
+
self._state = _State.STARTED
|
|
234
|
+
finally:
|
|
235
|
+
self._starting = False
|
|
236
|
+
|
|
237
|
+
async def _discard_partial_start(self) -> None:
|
|
238
|
+
# An aclose() that ran concurrently with start() cannot see the audio
|
|
239
|
+
# output and session id that start() set after it completed, so a failed
|
|
240
|
+
# start cleans them up itself, closing the audio output's background
|
|
241
|
+
# tasks rather than just dropping the reference. Only the sink this
|
|
242
|
+
# session installed is touched: it may now be wrapped by
|
|
243
|
+
# AgentSession.start() (TranscriptSynchronizer, RecorderAudioOutput), and
|
|
244
|
+
# the caller may have had their own DataStreamAudioOutput before this
|
|
245
|
+
# session ever ran, so neither the current chain head nor its type says
|
|
246
|
+
# what to close.
|
|
247
|
+
audio_output, self._audio_output = self._audio_output, None
|
|
248
|
+
if audio_output is not None and hasattr(audio_output, "aclose"):
|
|
249
|
+
await audio_output.aclose()
|
|
250
|
+
self._session_id = None
|
|
251
|
+
|
|
252
|
+
async def swap_avatar(self, avatar_id: str, *, timeout: float = DEFAULT_SWAP_TIMEOUT) -> str:
|
|
253
|
+
"""Switch the rendered avatar mid-session.
|
|
254
|
+
|
|
255
|
+
``avatar_id`` must be one of the ids passed in ``AvatarConfig``, or
|
|
256
|
+
``"default"`` for the first id in that list. Returns the now-active
|
|
257
|
+
avatar id once the swap has taken effect. Raises a ``SynthesiaError``
|
|
258
|
+
of type ``UNKNOWN_AVATAR`` for an id that was not precomputed,
|
|
259
|
+
``CONNECTION`` if the worker cannot be reached, and no ``type`` if the
|
|
260
|
+
worker rejects the swap.
|
|
261
|
+
"""
|
|
262
|
+
if self._state is not _State.STARTED or self._ending() or self._room is None:
|
|
263
|
+
raise SynthesiaError("swap_avatar() requires a started avatar session")
|
|
264
|
+
if avatar_id != "default" and avatar_id not in self._config.avatar_ids:
|
|
265
|
+
raise SynthesiaError(
|
|
266
|
+
f"avatar {avatar_id!r} was not in initial list of avatar_ids",
|
|
267
|
+
type=ErrorType.UNKNOWN_AVATAR,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
raw = await self._room.local_participant.perform_rpc(
|
|
272
|
+
destination_identity=self.avatar_identity,
|
|
273
|
+
method="swapAvatar",
|
|
274
|
+
payload=json.dumps({"avatar_id": avatar_id}),
|
|
275
|
+
response_timeout=timeout,
|
|
276
|
+
)
|
|
277
|
+
except Exception as e:
|
|
278
|
+
raise SynthesiaError(f"avatar swap RPC failed: {e}", type=ErrorType.CONNECTION) from e
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
response = json.loads(raw)
|
|
282
|
+
except ValueError as e:
|
|
283
|
+
raise SynthesiaError("avatar swap returned a malformed response") from e
|
|
284
|
+
avatar_id_result = response.get("avatar_id") if isinstance(response, dict) else None
|
|
285
|
+
if (
|
|
286
|
+
not isinstance(response, dict)
|
|
287
|
+
or response.get("error")
|
|
288
|
+
or not isinstance(avatar_id_result, str)
|
|
289
|
+
):
|
|
290
|
+
detail = response.get("error") if isinstance(response, dict) else None
|
|
291
|
+
raise SynthesiaError(f"avatar swap failed: {detail or raw}")
|
|
292
|
+
return avatar_id_result
|
|
293
|
+
|
|
294
|
+
def _mint_token(self, *, room: rtc.Room, lk_key: str, lk_secret: str) -> str:
|
|
295
|
+
# Synthesia rejects a token whose publish-on-behalf attribute is empty,
|
|
296
|
+
# since the worker has no agent to publish the avatar's audio for. Prefer
|
|
297
|
+
# the job context over room.local_participant: inside a job, the room may
|
|
298
|
+
# not have finished connecting yet when start() runs. Standalone callers
|
|
299
|
+
# (no job context) connect the room themselves before calling start(), so
|
|
300
|
+
# room.local_participant is safe there.
|
|
301
|
+
job_ctx = get_job_context(required=False)
|
|
302
|
+
agent_identity = (
|
|
303
|
+
job_ctx.local_participant_identity
|
|
304
|
+
if job_ctx is not None
|
|
305
|
+
else room.local_participant.identity
|
|
306
|
+
)
|
|
307
|
+
if not _present(agent_identity):
|
|
308
|
+
raise SynthesiaError(
|
|
309
|
+
"the room's local participant has no identity; connect the room "
|
|
310
|
+
"before starting the avatar session"
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
token = (
|
|
314
|
+
api.AccessToken(lk_key, lk_secret)
|
|
315
|
+
.with_identity(self.avatar_identity)
|
|
316
|
+
.with_name(self._avatar_name)
|
|
317
|
+
.with_kind("agent")
|
|
318
|
+
.with_grants(
|
|
319
|
+
api.VideoGrants(
|
|
320
|
+
room_join=True,
|
|
321
|
+
room=room.name,
|
|
322
|
+
can_publish=True,
|
|
323
|
+
can_subscribe=True,
|
|
324
|
+
can_publish_data=True,
|
|
325
|
+
)
|
|
326
|
+
)
|
|
327
|
+
.with_attributes({ATTRIBUTE_PUBLISH_ON_BEHALF: agent_identity})
|
|
328
|
+
.with_ttl(TOKEN_TTL)
|
|
329
|
+
)
|
|
330
|
+
return token.to_jwt()
|
|
331
|
+
|
|
332
|
+
def _on_room_disconnected(self, *args: object) -> None:
|
|
333
|
+
if self._ending():
|
|
334
|
+
return
|
|
335
|
+
logger.info("avatar session ended")
|
|
336
|
+
self._begin_teardown()
|
|
337
|
+
|
|
338
|
+
def _on_track_unpublished(
|
|
339
|
+
self,
|
|
340
|
+
publication: rtc.RemoteTrackPublication,
|
|
341
|
+
participant: rtc.RemoteParticipant,
|
|
342
|
+
) -> None:
|
|
343
|
+
if (
|
|
344
|
+
participant.identity == self.avatar_identity
|
|
345
|
+
and publication.kind == rtc.TrackKind.KIND_VIDEO
|
|
346
|
+
):
|
|
347
|
+
self._report_avatar_lost()
|
|
348
|
+
|
|
349
|
+
def _on_participant_disconnected(self, participant: rtc.RemoteParticipant) -> None:
|
|
350
|
+
if participant.identity == self.avatar_identity:
|
|
351
|
+
self._report_avatar_lost()
|
|
352
|
+
|
|
353
|
+
def _report_avatar_lost(self) -> None:
|
|
354
|
+
if self._ending():
|
|
355
|
+
return
|
|
356
|
+
# The avatar leaving while the room is still up is a worker crash. If the
|
|
357
|
+
# room itself is going down, this is a clean end and _on_room_disconnected
|
|
358
|
+
# reports it instead.
|
|
359
|
+
if self._room is not None and not self._room.isconnected():
|
|
360
|
+
return
|
|
361
|
+
logger.warning("avatar left the room unexpectedly")
|
|
362
|
+
self._begin_teardown()
|
|
363
|
+
|
|
364
|
+
def _ending(self) -> bool:
|
|
365
|
+
return self._state is _State.CLOSED or self._teardown_task is not None
|
|
366
|
+
|
|
367
|
+
def _begin_teardown(self) -> None:
|
|
368
|
+
if self._teardown_task is None and self._state is not _State.CLOSED:
|
|
369
|
+
self._teardown_task = asyncio.create_task(self.aclose())
|
|
370
|
+
self._teardown_task.add_done_callback(self._on_teardown_done)
|
|
371
|
+
|
|
372
|
+
def _on_teardown_done(self, task: asyncio.Task[None]) -> None:
|
|
373
|
+
if task.cancelled():
|
|
374
|
+
return
|
|
375
|
+
exc = task.exception()
|
|
376
|
+
if exc is not None:
|
|
377
|
+
logger.error("avatar teardown failed", exc_info=exc)
|
|
378
|
+
|
|
379
|
+
async def aclose(self) -> None:
|
|
380
|
+
if self._state is _State.CLOSED:
|
|
381
|
+
await self._close_done.wait()
|
|
382
|
+
return
|
|
383
|
+
self._state = _State.CLOSED
|
|
384
|
+
self._session_id = None
|
|
385
|
+
|
|
386
|
+
try:
|
|
387
|
+
# Only the sink this session installed is closed, by identity: it may
|
|
388
|
+
# now be wrapped by AgentSession.start() (TranscriptSynchronizer,
|
|
389
|
+
# RecorderAudioOutput), and the caller may have had their own
|
|
390
|
+
# DataStreamAudioOutput before this session ever ran, so neither the
|
|
391
|
+
# current chain head nor its type says what to close. The wrapper
|
|
392
|
+
# chain itself is left in place; there is no public API to remove a
|
|
393
|
+
# tail sink from it without supplying a replacement.
|
|
394
|
+
audio_output, self._audio_output = self._audio_output, None
|
|
395
|
+
if audio_output is not None and hasattr(audio_output, "aclose"):
|
|
396
|
+
await audio_output.aclose()
|
|
397
|
+
|
|
398
|
+
if self._room is not None:
|
|
399
|
+
self._room.off("disconnected", self._on_room_disconnected)
|
|
400
|
+
self._room.off("track_unpublished", self._on_track_unpublished)
|
|
401
|
+
self._room.off("participant_disconnected", self._on_participant_disconnected)
|
|
402
|
+
|
|
403
|
+
await super().aclose()
|
|
404
|
+
finally:
|
|
405
|
+
self._close_done.set()
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Copyright 2026 LiveKit, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import enum
|
|
18
|
+
import json
|
|
19
|
+
|
|
20
|
+
from livekit.agents import APIError
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ErrorType",
|
|
24
|
+
"SynthesiaError",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ErrorType(enum.Enum):
|
|
29
|
+
"""What went wrong, for callers that want to branch on it."""
|
|
30
|
+
|
|
31
|
+
AUTH = "auth"
|
|
32
|
+
"""The API key is invalid, expired, or lacks the scope this endpoint requires."""
|
|
33
|
+
|
|
34
|
+
FEATURE_NOT_IN_PLAN = "feature_not_in_plan"
|
|
35
|
+
"""The workspace's plan does not include interactive avatars."""
|
|
36
|
+
|
|
37
|
+
INVALID_ROOM_TOKEN = "invalid_room_token"
|
|
38
|
+
"""The room token cannot produce a joined session.
|
|
39
|
+
|
|
40
|
+
Distinct from ``AUTH``, which concerns the Synthesia API key. Raised for a
|
|
41
|
+
token that is malformed or missing the attribute naming the agent the
|
|
42
|
+
avatar publishes audio for.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
LIVEKIT_CREDENTIALS_REJECTED = "livekit_credentials_rejected"
|
|
46
|
+
"""The LiveKit project the token was signed for would not accept it.
|
|
47
|
+
|
|
48
|
+
The token itself is well formed, so the API key and secret it was minted
|
|
49
|
+
with are the thing to check, not the Synthesia credentials.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
INVALID_SESSION_REQUEST = "invalid_session_request"
|
|
53
|
+
"""The backend rejected the session request payload."""
|
|
54
|
+
|
|
55
|
+
UNKNOWN_AVATAR = "unknown_avatar"
|
|
56
|
+
"""Avatar is not in the gallery or not accessible to the workspace."""
|
|
57
|
+
|
|
58
|
+
QUOTA_EXCEEDED = "quota_exceeded"
|
|
59
|
+
"""The workspace's session quota is exhausted."""
|
|
60
|
+
|
|
61
|
+
RATE_LIMITED = "rate_limited"
|
|
62
|
+
"""The request was throttled. ``retry_after`` may carry the back-off."""
|
|
63
|
+
|
|
64
|
+
CONCURRENCY_LIMIT = "concurrency_limit"
|
|
65
|
+
"""Every concurrent-session slot is in use.
|
|
66
|
+
|
|
67
|
+
The API sends no ``Retry-After`` for this today, so ``retry_after`` is
|
|
68
|
+
normally ``None``; it is honoured if one ever arrives.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
TIMEOUT = "timeout"
|
|
72
|
+
"""The avatar did not join within ``join_timeout``."""
|
|
73
|
+
|
|
74
|
+
CONNECTION = "connection"
|
|
75
|
+
"""No usable response after retries.
|
|
76
|
+
|
|
77
|
+
The last attempt either failed to connect or was answered with a 5xx;
|
|
78
|
+
``status`` and ``request_id`` describe that answer when there was one.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_RETRYABLE_TYPES = {
|
|
83
|
+
ErrorType.RATE_LIMITED,
|
|
84
|
+
ErrorType.CONCURRENCY_LIMIT,
|
|
85
|
+
ErrorType.TIMEOUT,
|
|
86
|
+
ErrorType.CONNECTION,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class SynthesiaError(APIError):
|
|
91
|
+
"""Every error the Synthesia plugin raises. Also a :class:`~livekit.agents.APIError`.
|
|
92
|
+
|
|
93
|
+
- ``type`` identifies what went wrong; see :class:`ErrorType`.
|
|
94
|
+
- ``retryable`` reports whether retrying the same call could plausibly
|
|
95
|
+
succeed, and defaults based on ``type`` unless overridden.
|
|
96
|
+
- ``retry_after`` carries the server-provided back-off in seconds for a
|
|
97
|
+
``RATE_LIMITED`` or ``CONCURRENCY_LIMIT`` error, when the backend
|
|
98
|
+
supplied one.
|
|
99
|
+
- ``status`` is the HTTP status the API answered, or ``None`` when no
|
|
100
|
+
answer arrived.
|
|
101
|
+
- ``request_id`` is the API's ``requestId`` when the body carried one.
|
|
102
|
+
Quote it when contacting support.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
message: str,
|
|
108
|
+
*,
|
|
109
|
+
type: ErrorType | None = None,
|
|
110
|
+
body: object | None = None,
|
|
111
|
+
retryable: bool | None = None,
|
|
112
|
+
retry_after: float | None = None,
|
|
113
|
+
status: int | None = None,
|
|
114
|
+
request_id: str | None = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
if not isinstance(message, str):
|
|
117
|
+
message = _stringify(message)
|
|
118
|
+
self.type = type
|
|
119
|
+
self.retry_after = retry_after
|
|
120
|
+
self.status = status
|
|
121
|
+
self.request_id = request_id
|
|
122
|
+
if retryable is None:
|
|
123
|
+
retryable = type in _RETRYABLE_TYPES
|
|
124
|
+
super().__init__(message, body=body, retryable=retryable)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _stringify(value: object) -> str:
|
|
128
|
+
if isinstance(value, (dict, list)):
|
|
129
|
+
try:
|
|
130
|
+
return json.dumps(value, separators=(", ", ": "))
|
|
131
|
+
except (TypeError, ValueError):
|
|
132
|
+
pass
|
|
133
|
+
return str(value)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Copyright 2026 LiveKit, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("livekit.plugins.synthesia")
|
|
File without changes
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Copyright 2026 LiveKit, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import timedelta
|
|
20
|
+
|
|
21
|
+
DEFAULT_API_URL = "https://developers.synthesia.io"
|
|
22
|
+
DEFAULT_JOIN_TIMEOUT = 30.0
|
|
23
|
+
DEFAULT_SWAP_TIMEOUT = 15.0
|
|
24
|
+
AVATAR_IDENTITY = "synthesia-avatar-agent"
|
|
25
|
+
AVATAR_NAME = "Synthesia avatar"
|
|
26
|
+
MAX_AVATAR_IDS = 5
|
|
27
|
+
|
|
28
|
+
# Lifetime of the room-join token minted for the avatar worker. Long enough to
|
|
29
|
+
# cover the full session and any reconnects.
|
|
30
|
+
TOKEN_TTL = timedelta(hours=6)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class AvatarConfig:
|
|
35
|
+
"""The avatars to render in the room.
|
|
36
|
+
|
|
37
|
+
``avatar_ids`` holds one to five gallery ids of Synthesia avatars available
|
|
38
|
+
to your workspace. The first id is the active avatar; the rest are
|
|
39
|
+
available for swapping in during the session. Normalized to a tuple.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
avatar_ids: Sequence[str]
|
|
43
|
+
|
|
44
|
+
def __post_init__(self) -> None:
|
|
45
|
+
if isinstance(self.avatar_ids, (str, bytes)):
|
|
46
|
+
raise ValueError("avatar_ids must be a list of ids, not a single string")
|
|
47
|
+
self.avatar_ids = tuple(self.avatar_ids)
|
|
48
|
+
if not 1 <= len(self.avatar_ids) <= MAX_AVATAR_IDS:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
f"avatar_ids must contain between 1 and {MAX_AVATAR_IDS} ids, "
|
|
51
|
+
f"got {len(self.avatar_ids)}"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class SessionConfig:
|
|
57
|
+
avatar_ids: tuple[str, ...]
|
|
58
|
+
api_key: str = field(repr=False)
|
|
59
|
+
api_url: str
|
|
60
|
+
join_timeout: float
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class StartSessionRequest:
|
|
65
|
+
avatar_ids: list[str]
|
|
66
|
+
livekit_url: str
|
|
67
|
+
lk_token: str = field(repr=False)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class StartSessionResponse:
|
|
72
|
+
session_id: str
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Copyright 2026 LiveKit, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
__version__ = "1.8.2"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "livekit-plugins-synthesia"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "LiveKit Agents plugin for Synthesia interactive avatars"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.10.0"
|
|
12
|
+
authors = [{ name = "LiveKit", email = "support@livekit.io" }]
|
|
13
|
+
keywords = ["voice", "ai", "realtime", "audio", "video", "livekit", "webrtc", "avatar", "synthesia"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: Apache Software License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
22
|
+
"Topic :: Multimedia :: Sound/Audio",
|
|
23
|
+
"Topic :: Multimedia :: Video",
|
|
24
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
25
|
+
]
|
|
26
|
+
dependencies = ["livekit-agents>=1.8.2"]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Documentation = "https://docs.livekit.io"
|
|
30
|
+
Website = "https://livekit.io/"
|
|
31
|
+
Source = "https://github.com/livekit/agents"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.version]
|
|
34
|
+
path = "livekit/plugins/synthesia/version.py"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["livekit"]
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.sdist]
|
|
40
|
+
include = ["/livekit"]
|
|
41
|
+
|
|
42
|
+
[tool.uv]
|
|
43
|
+
exclude-newer = "7 days"
|
|
44
|
+
exclude-newer-package = { livekit-agents = "0 days" }
|