livekit-plugins-bland 1.7.1__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_bland-1.7.1/.gitignore +180 -0
- livekit_plugins_bland-1.7.1/PKG-INFO +38 -0
- livekit_plugins_bland-1.7.1/README.md +15 -0
- livekit_plugins_bland-1.7.1/livekit/plugins/bland/__init__.py +46 -0
- livekit_plugins_bland-1.7.1/livekit/plugins/bland/log.py +3 -0
- livekit_plugins_bland-1.7.1/livekit/plugins/bland/py.typed +0 -0
- livekit_plugins_bland-1.7.1/livekit/plugins/bland/tts.py +601 -0
- livekit_plugins_bland-1.7.1/livekit/plugins/bland/version.py +15 -0
- livekit_plugins_bland-1.7.1/pyproject.toml +43 -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,38 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: livekit-plugins-bland
|
|
3
|
+
Version: 1.7.1
|
|
4
|
+
Summary: LiveKit Agents Plugin for Bland
|
|
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 <hello@livekit.io>
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
Keywords: audio,bland,livekit,realtime,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 :: Scientific/Engineering :: Artificial Intelligence
|
|
20
|
+
Requires-Python: >=3.10.0
|
|
21
|
+
Requires-Dist: livekit-agents>=1.7.1
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# Bland plugin for LiveKit Agents
|
|
25
|
+
|
|
26
|
+
Support for voice synthesis with the [Bland](https://bland.ai/) API.
|
|
27
|
+
|
|
28
|
+
Voice agents can stream text through Bland's realtime WebSocket. Use `synthesize()` for complete strings over HTTP, or pass `streaming=False` to use HTTP for everything. See the [Bland realtime TTS reference](https://docs.bland.ai/api-v2/post/tts-ws).
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install livekit-plugins-bland
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Prerequisites
|
|
37
|
+
|
|
38
|
+
You'll need an API key from Bland. It can be set as an environment variable: `BLAND_API_KEY`
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Bland plugin for LiveKit Agents
|
|
2
|
+
|
|
3
|
+
Support for voice synthesis with the [Bland](https://bland.ai/) API.
|
|
4
|
+
|
|
5
|
+
Voice agents can stream text through Bland's realtime WebSocket. Use `synthesize()` for complete strings over HTTP, or pass `streaming=False` to use HTTP for everything. See the [Bland realtime TTS reference](https://docs.bland.ai/api-v2/post/tts-ws).
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install livekit-plugins-bland
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Prerequisites
|
|
14
|
+
|
|
15
|
+
You'll need an API key from Bland. It can be set as an environment variable: `BLAND_API_KEY`
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Copyright 2023 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
|
+
"""Bland plugin for LiveKit Agents"""
|
|
16
|
+
|
|
17
|
+
from .tts import TTS, ChunkedStream, SynthesizeStream
|
|
18
|
+
from .version import __version__
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"TTS",
|
|
22
|
+
"ChunkedStream",
|
|
23
|
+
"SynthesizeStream",
|
|
24
|
+
"__version__",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
from livekit.agents import Plugin
|
|
28
|
+
|
|
29
|
+
from .log import logger
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BlandPlugin(Plugin):
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
super().__init__(__name__, __version__, __package__, logger)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
Plugin.register_plugin(BlandPlugin())
|
|
38
|
+
|
|
39
|
+
# Cleanup docs of unexported modules
|
|
40
|
+
_module = dir()
|
|
41
|
+
NOT_IN_ALL = [m for m in _module if m not in __all__]
|
|
42
|
+
|
|
43
|
+
__pdoc__ = {}
|
|
44
|
+
|
|
45
|
+
for n in NOT_IN_ALL:
|
|
46
|
+
__pdoc__[n] = False
|
|
File without changes
|
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
# Copyright 2023 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
|
+
import os
|
|
20
|
+
import weakref
|
|
21
|
+
from dataclasses import dataclass, replace
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import aiohttp
|
|
25
|
+
|
|
26
|
+
from livekit.agents import (
|
|
27
|
+
APIConnectionError,
|
|
28
|
+
APIConnectOptions,
|
|
29
|
+
APIError,
|
|
30
|
+
APIStatusError,
|
|
31
|
+
APITimeoutError,
|
|
32
|
+
tts,
|
|
33
|
+
utils,
|
|
34
|
+
)
|
|
35
|
+
from livekit.agents.types import DEFAULT_API_CONNECT_OPTIONS, NOT_GIVEN, NotGivenOr
|
|
36
|
+
from livekit.agents.utils import is_given
|
|
37
|
+
|
|
38
|
+
from .log import logger
|
|
39
|
+
|
|
40
|
+
DEFAULT_BASE_URL = "https://api.bland.ai/v2"
|
|
41
|
+
DEFAULT_VOICE_ID = "2f29fdbb-c55e-4add-9c7c-93437ebf379d"
|
|
42
|
+
DEFAULT_SAMPLE_RATE = 48000
|
|
43
|
+
NUM_CHANNELS = 1
|
|
44
|
+
|
|
45
|
+
SAMPLE_RATES = (8000, 16000, 24000, 44100, 48000)
|
|
46
|
+
|
|
47
|
+
# Bland ends a session that goes 60s without a client message. The pool clock is
|
|
48
|
+
# refreshed on every acquire, so this expires a socket that has sat unused across a
|
|
49
|
+
# long conversational gap rather than handing back one the server already dropped.
|
|
50
|
+
_MAX_SESSION_DURATION = 50
|
|
51
|
+
|
|
52
|
+
# How long a barge-in waits for the cancelled turn's terminal before giving up on
|
|
53
|
+
# the socket. Answering `cancel` is a state flip on the server, so this only ever
|
|
54
|
+
# covers a round trip; anything slower is a socket worth replacing. Deliberately
|
|
55
|
+
# not derived from `conn_options.timeout` — that budget is for establishing a
|
|
56
|
+
# connection, and spending it here stalls the next turn behind a dead one.
|
|
57
|
+
_CANCEL_DRAIN_TIMEOUT = 0.5
|
|
58
|
+
|
|
59
|
+
# Errors that a retry cannot fix: bad credentials, a bad request, or an account that
|
|
60
|
+
# needs attention. Everything else (synthesis failures, a busy concurrency pool) is
|
|
61
|
+
# worth another attempt.
|
|
62
|
+
_FATAL_ERROR_CODES = frozenset(
|
|
63
|
+
{
|
|
64
|
+
"AUTH_FAILED",
|
|
65
|
+
"INSUFFICIENT_CREDITS",
|
|
66
|
+
"ORG_DELETED",
|
|
67
|
+
"ORG_STRIPE_OVERDUE",
|
|
68
|
+
"ORG_SUSPENDED",
|
|
69
|
+
"USER_BANNED",
|
|
70
|
+
"already_initialized",
|
|
71
|
+
"context_overflow",
|
|
72
|
+
"init_required",
|
|
73
|
+
"insufficient_credits",
|
|
74
|
+
"invalid_message",
|
|
75
|
+
"invalid_request",
|
|
76
|
+
"unsupported_encoding",
|
|
77
|
+
"unsupported_sample_rate",
|
|
78
|
+
"unsupported_voice",
|
|
79
|
+
"voice_not_found",
|
|
80
|
+
"voice_not_live",
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class _TTSOptions:
|
|
87
|
+
voice_id: str
|
|
88
|
+
sample_rate: int
|
|
89
|
+
expressiveness: NotGivenOr[float]
|
|
90
|
+
stability: NotGivenOr[float]
|
|
91
|
+
base_url: str
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TTS(tts.TTS):
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
*,
|
|
98
|
+
voice_id: str = DEFAULT_VOICE_ID,
|
|
99
|
+
sample_rate: int = DEFAULT_SAMPLE_RATE,
|
|
100
|
+
expressiveness: NotGivenOr[float] = NOT_GIVEN,
|
|
101
|
+
stability: NotGivenOr[float] = NOT_GIVEN,
|
|
102
|
+
api_key: NotGivenOr[str] = NOT_GIVEN,
|
|
103
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
104
|
+
streaming: bool = True,
|
|
105
|
+
http_session: aiohttp.ClientSession | None = None,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Create a new instance of the Bland TTS.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
voice_id: Bland voice UUID; names are not accepted. Defaults to a ``BTTS_V3``
|
|
111
|
+
voice. ``BTTS_V2`` voices work, but the controls below are calibrated for
|
|
112
|
+
``BTTS_V3``.
|
|
113
|
+
sample_rate: Output sample rate in Hz, one of 8000, 16000, 24000, 44100, 48000.
|
|
114
|
+
Defaults to 48000, the rate ``BTTS_V3`` renders natively.
|
|
115
|
+
expressiveness: 0.0-1.0. Higher is more varied intonation.
|
|
116
|
+
stability: 0.0-1.0. Higher is more consistent between renders.
|
|
117
|
+
api_key: Bland API key. Falls back to the ``BLAND_API_KEY`` environment variable.
|
|
118
|
+
base_url: Override the Bland API base URL.
|
|
119
|
+
streaming: Stream text into one realtime WebSocket session, which is what a
|
|
120
|
+
voice agent wants: audio starts before the sentence is finished, and a
|
|
121
|
+
barge-in cancels the turn in place. Set False to synthesize each
|
|
122
|
+
utterance with a single HTTP request instead — no session is held open,
|
|
123
|
+
and no concurrency slot with it, which suits a pipeline that speaks
|
|
124
|
+
rarely. ``synthesize()`` uses HTTP either way.
|
|
125
|
+
http_session: Optional ``aiohttp.ClientSession`` to reuse.
|
|
126
|
+
"""
|
|
127
|
+
if sample_rate not in SAMPLE_RATES:
|
|
128
|
+
raise ValueError(f"sample_rate must be one of {SAMPLE_RATES}, got {sample_rate}")
|
|
129
|
+
|
|
130
|
+
super().__init__(
|
|
131
|
+
capabilities=tts.TTSCapabilities(streaming=streaming),
|
|
132
|
+
sample_rate=sample_rate,
|
|
133
|
+
num_channels=NUM_CHANNELS,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
bland_api_key = api_key if is_given(api_key) else os.environ.get("BLAND_API_KEY")
|
|
137
|
+
if not bland_api_key:
|
|
138
|
+
raise ValueError(
|
|
139
|
+
"Bland API key is required, either as `api_key` argument or "
|
|
140
|
+
"`BLAND_API_KEY` environment variable"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
self._api_key = bland_api_key
|
|
144
|
+
self._opts = _TTSOptions(
|
|
145
|
+
voice_id=voice_id,
|
|
146
|
+
sample_rate=sample_rate,
|
|
147
|
+
expressiveness=expressiveness,
|
|
148
|
+
stability=stability,
|
|
149
|
+
base_url=base_url.rstrip("/"),
|
|
150
|
+
)
|
|
151
|
+
self._session = http_session
|
|
152
|
+
self._streams = weakref.WeakSet[SynthesizeStream]()
|
|
153
|
+
# No pool when streaming is off: nothing would ever check a socket out of it,
|
|
154
|
+
# and an open session holds a concurrency slot for as long as it lives.
|
|
155
|
+
self._pool = (
|
|
156
|
+
utils.ConnectionPool[aiohttp.ClientWebSocketResponse](
|
|
157
|
+
connect_cb=self._connect_ws,
|
|
158
|
+
close_cb=self._close_ws,
|
|
159
|
+
max_session_duration=_MAX_SESSION_DURATION,
|
|
160
|
+
mark_refreshed_on_get=True,
|
|
161
|
+
)
|
|
162
|
+
if streaming
|
|
163
|
+
else None
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def provider(self) -> str:
|
|
168
|
+
return "Bland"
|
|
169
|
+
|
|
170
|
+
def _ensure_session(self) -> aiohttp.ClientSession:
|
|
171
|
+
if not self._session:
|
|
172
|
+
self._session = utils.http_context.http_session()
|
|
173
|
+
return self._session
|
|
174
|
+
|
|
175
|
+
async def _connect_ws(self, timeout: float) -> aiohttp.ClientWebSocketResponse:
|
|
176
|
+
"""Open a session and hold it at `ready`, so a turn starts on the first `speak`."""
|
|
177
|
+
try:
|
|
178
|
+
ws = await asyncio.wait_for(
|
|
179
|
+
self._ensure_session().ws_connect(
|
|
180
|
+
_ws_url(self._opts.base_url),
|
|
181
|
+
headers={"Authorization": f"Bearer {self._api_key}"},
|
|
182
|
+
),
|
|
183
|
+
timeout,
|
|
184
|
+
)
|
|
185
|
+
except aiohttp.WSServerHandshakeError as e:
|
|
186
|
+
# Bland answers an upgrade carrying no credential with a real 401 rather
|
|
187
|
+
# than accepting and closing, so this is the path a bad key takes.
|
|
188
|
+
raise APIStatusError(
|
|
189
|
+
message=e.message,
|
|
190
|
+
status_code=e.status,
|
|
191
|
+
request_id=e.headers.get("x-request-id") if e.headers else None,
|
|
192
|
+
) from e
|
|
193
|
+
try:
|
|
194
|
+
init: dict[str, Any] = {
|
|
195
|
+
"type": "init",
|
|
196
|
+
"voice": self._opts.voice_id,
|
|
197
|
+
"audio": {"encoding": "pcm_s16le", "sample_rate": self._opts.sample_rate},
|
|
198
|
+
}
|
|
199
|
+
if controls := _controls(self._opts):
|
|
200
|
+
init["controls"] = controls
|
|
201
|
+
await ws.send_str(json.dumps(init))
|
|
202
|
+
|
|
203
|
+
msg = await asyncio.wait_for(ws.receive(), timeout)
|
|
204
|
+
if msg.type is not aiohttp.WSMsgType.TEXT:
|
|
205
|
+
raise APIError(f"Bland did not acknowledge init: {msg.type}")
|
|
206
|
+
|
|
207
|
+
data = json.loads(msg.data)
|
|
208
|
+
if data.get("type") != "ready":
|
|
209
|
+
raise _api_error(data)
|
|
210
|
+
if (
|
|
211
|
+
data.get("encoding") != "pcm_s16le"
|
|
212
|
+
or data.get("sample_rate") != self._opts.sample_rate
|
|
213
|
+
):
|
|
214
|
+
raise APIError(
|
|
215
|
+
"Bland acknowledged an unexpected audio format",
|
|
216
|
+
body=data,
|
|
217
|
+
retryable=False,
|
|
218
|
+
)
|
|
219
|
+
except BaseException:
|
|
220
|
+
try:
|
|
221
|
+
await ws.close()
|
|
222
|
+
except Exception:
|
|
223
|
+
pass
|
|
224
|
+
raise
|
|
225
|
+
|
|
226
|
+
logger.debug("Bland TTS session ready", extra={"session_id": data.get("session_id")})
|
|
227
|
+
return ws
|
|
228
|
+
|
|
229
|
+
async def _close_ws(self, ws: aiohttp.ClientWebSocketResponse) -> None:
|
|
230
|
+
"""Settle the session rather than dropping the socket, so usage is reconciled once."""
|
|
231
|
+
if ws.closed:
|
|
232
|
+
return
|
|
233
|
+
try:
|
|
234
|
+
await ws.send_str(json.dumps({"type": "close"}))
|
|
235
|
+
await asyncio.wait_for(ws.receive(), timeout=1.0)
|
|
236
|
+
except Exception as e:
|
|
237
|
+
logger.debug("Bland TTS close handshake skipped", extra={"error": str(e)})
|
|
238
|
+
finally:
|
|
239
|
+
await ws.close()
|
|
240
|
+
|
|
241
|
+
def update_options(
|
|
242
|
+
self,
|
|
243
|
+
*,
|
|
244
|
+
voice_id: NotGivenOr[str] = NOT_GIVEN,
|
|
245
|
+
expressiveness: NotGivenOr[float] = NOT_GIVEN,
|
|
246
|
+
stability: NotGivenOr[float] = NOT_GIVEN,
|
|
247
|
+
) -> None:
|
|
248
|
+
changed = False
|
|
249
|
+
if is_given(voice_id):
|
|
250
|
+
self._opts.voice_id = voice_id
|
|
251
|
+
changed = True
|
|
252
|
+
if is_given(expressiveness):
|
|
253
|
+
self._opts.expressiveness = expressiveness
|
|
254
|
+
changed = True
|
|
255
|
+
if is_given(stability):
|
|
256
|
+
self._opts.stability = stability
|
|
257
|
+
changed = True
|
|
258
|
+
|
|
259
|
+
if changed:
|
|
260
|
+
# `init` fixes the voice and controls for the life of a session, so a pooled
|
|
261
|
+
# socket would keep serving the old ones.
|
|
262
|
+
if self._pool is not None:
|
|
263
|
+
self._pool.invalidate()
|
|
264
|
+
|
|
265
|
+
def synthesize(
|
|
266
|
+
self, text: str, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
|
|
267
|
+
) -> ChunkedStream:
|
|
268
|
+
return ChunkedStream(tts=self, input_text=text, conn_options=conn_options)
|
|
269
|
+
|
|
270
|
+
def stream(
|
|
271
|
+
self, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
|
|
272
|
+
) -> SynthesizeStream:
|
|
273
|
+
if self._pool is None:
|
|
274
|
+
raise RuntimeError(
|
|
275
|
+
"streaming is disabled on this Bland TTS instance; construct it with "
|
|
276
|
+
"`streaming=True`, or wrap it in a `tts.StreamAdapter`"
|
|
277
|
+
)
|
|
278
|
+
stream = SynthesizeStream(tts=self, conn_options=conn_options)
|
|
279
|
+
self._streams.add(stream)
|
|
280
|
+
return stream
|
|
281
|
+
|
|
282
|
+
def prewarm(self) -> None:
|
|
283
|
+
if self._pool is not None:
|
|
284
|
+
self._pool.prewarm()
|
|
285
|
+
|
|
286
|
+
async def aclose(self) -> None:
|
|
287
|
+
for stream in list(self._streams):
|
|
288
|
+
await stream.aclose()
|
|
289
|
+
|
|
290
|
+
self._streams.clear()
|
|
291
|
+
if self._pool is not None:
|
|
292
|
+
await self._pool.aclose()
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class ChunkedStream(tts.ChunkedStream):
|
|
296
|
+
"""Synthesize a complete string over HTTP."""
|
|
297
|
+
|
|
298
|
+
def __init__(self, *, tts: TTS, input_text: str, conn_options: APIConnectOptions) -> None:
|
|
299
|
+
super().__init__(tts=tts, input_text=input_text, conn_options=conn_options)
|
|
300
|
+
self._tts: TTS = tts
|
|
301
|
+
self._opts = replace(tts._opts)
|
|
302
|
+
|
|
303
|
+
async def _run(self, output_emitter: tts.AudioEmitter) -> None:
|
|
304
|
+
body: dict[str, Any] = {
|
|
305
|
+
"text": self._input_text,
|
|
306
|
+
"voice": self._opts.voice_id,
|
|
307
|
+
"audio": {"encoding": "pcm_s16le", "sample_rate": self._opts.sample_rate},
|
|
308
|
+
}
|
|
309
|
+
if controls := _controls(self._opts):
|
|
310
|
+
body["controls"] = controls
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
async with self._tts._ensure_session().post(
|
|
314
|
+
f"{self._opts.base_url}/tts",
|
|
315
|
+
headers={
|
|
316
|
+
"authorization": self._tts._api_key,
|
|
317
|
+
"content-type": "application/json",
|
|
318
|
+
},
|
|
319
|
+
json=body,
|
|
320
|
+
timeout=aiohttp.ClientTimeout(total=30, sock_connect=self._conn_options.timeout),
|
|
321
|
+
) as resp:
|
|
322
|
+
if resp.status != 200:
|
|
323
|
+
raise APIStatusError(
|
|
324
|
+
message=await _error_message(resp),
|
|
325
|
+
status_code=resp.status,
|
|
326
|
+
request_id=resp.headers.get("x-request-id"),
|
|
327
|
+
body=None,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
output_emitter.initialize(
|
|
331
|
+
request_id=resp.headers.get("x-request-id") or utils.shortuuid(),
|
|
332
|
+
sample_rate=self._opts.sample_rate,
|
|
333
|
+
num_channels=NUM_CHANNELS,
|
|
334
|
+
mime_type="audio/pcm",
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
async for data, _ in resp.content.iter_chunks():
|
|
338
|
+
output_emitter.push(data)
|
|
339
|
+
|
|
340
|
+
output_emitter.flush()
|
|
341
|
+
except asyncio.TimeoutError:
|
|
342
|
+
raise APITimeoutError() from None
|
|
343
|
+
except APIStatusError:
|
|
344
|
+
raise
|
|
345
|
+
except Exception as e:
|
|
346
|
+
raise APIConnectionError() from e
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class SynthesizeStream(tts.SynthesizeStream):
|
|
350
|
+
"""Stream text into a session and receive audio as it is rendered.
|
|
351
|
+
|
|
352
|
+
Bland accumulates the text deltas server-side and picks its own synthesis
|
|
353
|
+
boundaries, so tokens go out as they arrive: no sentence tokenizer, no character
|
|
354
|
+
threshold, and no flush after every fragment.
|
|
355
|
+
"""
|
|
356
|
+
|
|
357
|
+
def __init__(self, *, tts: TTS, conn_options: APIConnectOptions) -> None:
|
|
358
|
+
super().__init__(tts=tts, conn_options=conn_options)
|
|
359
|
+
self._tts: TTS = tts
|
|
360
|
+
# `TTS.stream()` refuses to build one of these without a pool, so this is
|
|
361
|
+
# always present — bound once here rather than re-narrowed at each use.
|
|
362
|
+
assert tts._pool is not None
|
|
363
|
+
self._pool = tts._pool
|
|
364
|
+
self._opts = replace(tts._opts)
|
|
365
|
+
|
|
366
|
+
async def _run(self, output_emitter: tts.AudioEmitter) -> None:
|
|
367
|
+
# One stream is one segment, which is one Bland turn: the socket carries a
|
|
368
|
+
# single turn at a time and the framework opens a stream per segment.
|
|
369
|
+
context_id = utils.shortuuid()
|
|
370
|
+
output_emitter.initialize(
|
|
371
|
+
request_id=context_id,
|
|
372
|
+
sample_rate=self._opts.sample_rate,
|
|
373
|
+
num_channels=NUM_CHANNELS,
|
|
374
|
+
mime_type="audio/pcm",
|
|
375
|
+
stream=True,
|
|
376
|
+
)
|
|
377
|
+
output_emitter.start_segment(segment_id=context_id)
|
|
378
|
+
input_sent = asyncio.Event()
|
|
379
|
+
text_sent = False
|
|
380
|
+
|
|
381
|
+
async def send_task(ws: aiohttp.ClientWebSocketResponse) -> None:
|
|
382
|
+
nonlocal text_sent
|
|
383
|
+
async for data in self._input_ch:
|
|
384
|
+
if isinstance(data, self._FlushSentinel):
|
|
385
|
+
continue
|
|
386
|
+
|
|
387
|
+
text_sent = True
|
|
388
|
+
self._mark_started()
|
|
389
|
+
await ws.send_str(
|
|
390
|
+
json.dumps({"type": "speak", "context_id": context_id, "text": data})
|
|
391
|
+
)
|
|
392
|
+
input_sent.set()
|
|
393
|
+
|
|
394
|
+
if not text_sent:
|
|
395
|
+
output_emitter.end_segment()
|
|
396
|
+
input_sent.set()
|
|
397
|
+
return
|
|
398
|
+
|
|
399
|
+
# Bland holds a short tail back waiting for more context; this releases it.
|
|
400
|
+
await ws.send_str(json.dumps({"type": "end_of_turn", "context_id": context_id}))
|
|
401
|
+
input_sent.set()
|
|
402
|
+
|
|
403
|
+
async def recv_task(ws: aiohttp.ClientWebSocketResponse) -> None:
|
|
404
|
+
await input_sent.wait()
|
|
405
|
+
if not text_sent:
|
|
406
|
+
return
|
|
407
|
+
while True:
|
|
408
|
+
msg = await ws.receive(timeout=self._conn_options.timeout)
|
|
409
|
+
if msg.type in (
|
|
410
|
+
aiohttp.WSMsgType.CLOSE,
|
|
411
|
+
aiohttp.WSMsgType.CLOSED,
|
|
412
|
+
aiohttp.WSMsgType.CLOSING,
|
|
413
|
+
):
|
|
414
|
+
raise APIStatusError(
|
|
415
|
+
"Bland connection closed unexpectedly",
|
|
416
|
+
status_code=ws.close_code or -1,
|
|
417
|
+
body=f"{msg.data=} {msg.extra=}",
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
if msg.type is aiohttp.WSMsgType.BINARY:
|
|
421
|
+
output_emitter.push(msg.data)
|
|
422
|
+
continue
|
|
423
|
+
|
|
424
|
+
if msg.type is not aiohttp.WSMsgType.TEXT:
|
|
425
|
+
logger.warning("unexpected Bland message type %s", msg.type)
|
|
426
|
+
continue
|
|
427
|
+
|
|
428
|
+
data = json.loads(msg.data)
|
|
429
|
+
event = data.get("type")
|
|
430
|
+
if event == "utterance_end":
|
|
431
|
+
# A stale terminator can only belong to a turn this stream already
|
|
432
|
+
# abandoned, so it is not this turn's boundary.
|
|
433
|
+
if data.get("context_id") != context_id:
|
|
434
|
+
continue
|
|
435
|
+
if (reason := data.get("reason")) != "complete":
|
|
436
|
+
raise APIError(f"Bland turn ended as {reason}", body=data)
|
|
437
|
+
output_emitter.end_segment()
|
|
438
|
+
return
|
|
439
|
+
elif event == "utterance_start":
|
|
440
|
+
continue
|
|
441
|
+
elif event == "error":
|
|
442
|
+
raise _api_error(data)
|
|
443
|
+
else:
|
|
444
|
+
logger.warning("unexpected Bland message %s", data)
|
|
445
|
+
|
|
446
|
+
async def cancel_and_drain(ws: aiohttp.ClientWebSocketResponse) -> None:
|
|
447
|
+
"""Cancel the active turn and consume its terminal event before reuse."""
|
|
448
|
+
await ws.send_str(json.dumps({"type": "cancel", "context_id": context_id}))
|
|
449
|
+
while True:
|
|
450
|
+
msg = await ws.receive()
|
|
451
|
+
if msg.type in (
|
|
452
|
+
aiohttp.WSMsgType.CLOSE,
|
|
453
|
+
aiohttp.WSMsgType.CLOSED,
|
|
454
|
+
aiohttp.WSMsgType.CLOSING,
|
|
455
|
+
):
|
|
456
|
+
raise APIStatusError(
|
|
457
|
+
"Bland connection closed while cancelling a turn",
|
|
458
|
+
status_code=ws.close_code or -1,
|
|
459
|
+
body=f"{msg.data=} {msg.extra=}",
|
|
460
|
+
)
|
|
461
|
+
if msg.type is aiohttp.WSMsgType.BINARY:
|
|
462
|
+
continue
|
|
463
|
+
if msg.type is not aiohttp.WSMsgType.TEXT:
|
|
464
|
+
continue
|
|
465
|
+
|
|
466
|
+
data = json.loads(msg.data)
|
|
467
|
+
if data.get("context_id") != context_id:
|
|
468
|
+
continue
|
|
469
|
+
if data.get("type") == "utterance_end":
|
|
470
|
+
if data.get("reason") in ("cancelled", "complete"):
|
|
471
|
+
return
|
|
472
|
+
raise APIError("Bland did not cancel the turn cleanly", body=data)
|
|
473
|
+
if data.get("type") == "error":
|
|
474
|
+
# An admission failure creates no turn and emits no terminal, so
|
|
475
|
+
# there is nothing left to drain to. Returning here would hand the
|
|
476
|
+
# socket back reusable — but from this side an admission refusal
|
|
477
|
+
# is indistinguishable from a mid-turn error whose terminal is
|
|
478
|
+
# still in flight, and that terminal would surface against the
|
|
479
|
+
# next, unrelated turn. Raising closes the socket, which costs one
|
|
480
|
+
# reconnect on a path that has already failed and keeps turns from
|
|
481
|
+
# contaminating each other.
|
|
482
|
+
raise _api_error(data)
|
|
483
|
+
|
|
484
|
+
cancelled: asyncio.CancelledError | None = None
|
|
485
|
+
try:
|
|
486
|
+
async with self._pool.connection(timeout=self._conn_options.timeout) as ws:
|
|
487
|
+
self._acquire_time = self._pool.last_acquire_time
|
|
488
|
+
self._connection_reused = self._pool.last_connection_reused
|
|
489
|
+
tasks = [
|
|
490
|
+
asyncio.create_task(send_task(ws)),
|
|
491
|
+
asyncio.create_task(recv_task(ws)),
|
|
492
|
+
]
|
|
493
|
+
|
|
494
|
+
try:
|
|
495
|
+
await asyncio.gather(*tasks)
|
|
496
|
+
except asyncio.CancelledError as e:
|
|
497
|
+
turn_was_sent = input_sent.is_set()
|
|
498
|
+
input_sent.set()
|
|
499
|
+
await utils.aio.gracefully_cancel(*tasks)
|
|
500
|
+
if text_sent and not turn_was_sent:
|
|
501
|
+
# Cancellation interrupted the first write, so whether the
|
|
502
|
+
# server owns this context is unknowable. Do not reuse it.
|
|
503
|
+
try:
|
|
504
|
+
await ws.close()
|
|
505
|
+
except Exception:
|
|
506
|
+
pass
|
|
507
|
+
raise
|
|
508
|
+
if text_sent:
|
|
509
|
+
try:
|
|
510
|
+
# Bounded: a socket that does not answer must not hold up
|
|
511
|
+
# the barge-in that is waiting on this teardown.
|
|
512
|
+
await asyncio.wait_for(
|
|
513
|
+
cancel_and_drain(ws), timeout=_CANCEL_DRAIN_TIMEOUT
|
|
514
|
+
)
|
|
515
|
+
except asyncio.CancelledError:
|
|
516
|
+
try:
|
|
517
|
+
await ws.close()
|
|
518
|
+
except Exception:
|
|
519
|
+
pass
|
|
520
|
+
raise
|
|
521
|
+
except BaseException as drain_error:
|
|
522
|
+
# Tidying up failed, but this is still a cancellation, and
|
|
523
|
+
# it has to leave as one. Letting the drain's own error
|
|
524
|
+
# escape would turn a barge-in into a retryable API error:
|
|
525
|
+
# the framework replays the buffered text, so the caller
|
|
526
|
+
# hears the interrupted sentence a second time — and when
|
|
527
|
+
# the cancel came from `aclose()` before `end_input()`, the
|
|
528
|
+
# replay waits forever on an input channel nothing will
|
|
529
|
+
# close, with the one cancellation already spent.
|
|
530
|
+
logger.debug(
|
|
531
|
+
"Bland cancel handshake failed",
|
|
532
|
+
extra={"error": str(drain_error)},
|
|
533
|
+
)
|
|
534
|
+
try:
|
|
535
|
+
await ws.close()
|
|
536
|
+
except Exception:
|
|
537
|
+
pass
|
|
538
|
+
raise e from drain_error
|
|
539
|
+
# Exit the pool context normally so this clean, drained session is
|
|
540
|
+
# returned for the next turn, then preserve caller cancellation.
|
|
541
|
+
cancelled = e
|
|
542
|
+
except BaseException:
|
|
543
|
+
# A failed stream cannot safely return a socket with unread turn
|
|
544
|
+
# state to the pool.
|
|
545
|
+
try:
|
|
546
|
+
await ws.close()
|
|
547
|
+
except Exception:
|
|
548
|
+
pass
|
|
549
|
+
raise
|
|
550
|
+
finally:
|
|
551
|
+
input_sent.set()
|
|
552
|
+
await utils.aio.gracefully_cancel(*tasks)
|
|
553
|
+
if cancelled is not None:
|
|
554
|
+
raise cancelled
|
|
555
|
+
except asyncio.TimeoutError:
|
|
556
|
+
raise APITimeoutError() from None
|
|
557
|
+
except APIError:
|
|
558
|
+
raise
|
|
559
|
+
except Exception as e:
|
|
560
|
+
raise APIConnectionError() from e
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _ws_url(base_url: str) -> str:
|
|
564
|
+
base_url = base_url.rstrip("/")
|
|
565
|
+
return f"{base_url.replace('https://', 'wss://', 1).replace('http://', 'ws://', 1)}/tts/ws"
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _controls(opts: _TTSOptions) -> dict[str, float]:
|
|
569
|
+
controls: dict[str, float] = {}
|
|
570
|
+
if is_given(opts.expressiveness):
|
|
571
|
+
controls["expressiveness"] = opts.expressiveness
|
|
572
|
+
if is_given(opts.stability):
|
|
573
|
+
controls["stability"] = opts.stability
|
|
574
|
+
return controls
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _api_error(data: dict[str, Any]) -> APIError:
|
|
578
|
+
code = data.get("code")
|
|
579
|
+
message = data.get("message") or "Bland returned an error"
|
|
580
|
+
return APIError(
|
|
581
|
+
f"{code}: {message}" if code else message,
|
|
582
|
+
body=data,
|
|
583
|
+
retryable=code not in _FATAL_ERROR_CODES,
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
async def _error_message(resp: aiohttp.ClientResponse) -> str:
|
|
588
|
+
"""Unwrap the v2 ``{"error": {"code", "message"}}`` envelope, falling back to the raw body."""
|
|
589
|
+
try:
|
|
590
|
+
payload = await resp.json()
|
|
591
|
+
except Exception:
|
|
592
|
+
return resp.reason or "request failed"
|
|
593
|
+
|
|
594
|
+
error = payload.get("error") if isinstance(payload, dict) else None
|
|
595
|
+
if isinstance(error, dict):
|
|
596
|
+
code, message = error.get("code"), error.get("message")
|
|
597
|
+
if code and message:
|
|
598
|
+
return f"{code}: {message}"
|
|
599
|
+
if message:
|
|
600
|
+
return str(message)
|
|
601
|
+
return str(payload)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Copyright 2023 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.7.1"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "livekit-plugins-bland"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "LiveKit Agents Plugin for Bland"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.10.0"
|
|
12
|
+
authors = [{ name = "LiveKit", email = "hello@livekit.io" }]
|
|
13
|
+
keywords = ["webrtc", "realtime", "audio", "livekit", "bland"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: Apache Software License",
|
|
17
|
+
"Topic :: Multimedia :: Sound/Audio",
|
|
18
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
24
|
+
]
|
|
25
|
+
dependencies = ["livekit-agents>=1.7.1"]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Documentation = "https://docs.livekit.io"
|
|
29
|
+
Website = "https://livekit.io/"
|
|
30
|
+
Source = "https://github.com/livekit/agents"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.version]
|
|
33
|
+
path = "livekit/plugins/bland/version.py"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["livekit"]
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.sdist]
|
|
39
|
+
include = ["/livekit"]
|
|
40
|
+
|
|
41
|
+
[tool.uv]
|
|
42
|
+
exclude-newer = "7 days"
|
|
43
|
+
exclude-newer-package = { livekit-agents = "0 days" }
|