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.
@@ -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
@@ -0,0 +1,3 @@
1
+ import logging
2
+
3
+ logger = logging.getLogger("livekit.plugins.bland")
@@ -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" }