Module livekit.plugins.bland
Bland plugin for LiveKit Agents
Classes
class ChunkedStream (*,
tts: TTS,
input_text: str,
conn_options: APIConnectOptions)-
Expand source code
class ChunkedStream(tts.ChunkedStream): """Synthesize a complete string over HTTP.""" def __init__(self, *, tts: TTS, input_text: str, conn_options: APIConnectOptions) -> None: super().__init__(tts=tts, input_text=input_text, conn_options=conn_options) self._tts: TTS = tts self._opts = replace(tts._opts) async def _run(self, output_emitter: tts.AudioEmitter) -> None: body: dict[str, Any] = { "text": self._input_text, "voice": self._opts.voice_id, "audio": {"encoding": "pcm_s16le", "sample_rate": self._opts.sample_rate}, } if controls := _controls(self._opts): body["controls"] = controls try: async with self._tts._ensure_session().post( f"{self._opts.base_url}/tts", headers={ "authorization": self._tts._api_key, "content-type": "application/json", }, json=body, timeout=aiohttp.ClientTimeout(total=30, sock_connect=self._conn_options.timeout), ) as resp: if resp.status != 200: raise APIStatusError( message=await _error_message(resp), status_code=resp.status, request_id=resp.headers.get("x-request-id"), body=None, ) output_emitter.initialize( request_id=resp.headers.get("x-request-id") or utils.shortuuid(), sample_rate=self._opts.sample_rate, num_channels=NUM_CHANNELS, mime_type="audio/pcm", ) async for data, _ in resp.content.iter_chunks(): output_emitter.push(data) output_emitter.flush() except asyncio.TimeoutError: raise APITimeoutError() from None except APIStatusError: raise except Exception as e: raise APIConnectionError() from eSynthesize a complete string over HTTP.
Ancestors
- livekit.agents.tts.tts.ChunkedStream
- abc.ABC
class SynthesizeStream (*,
tts: TTS,
conn_options: APIConnectOptions)-
Expand source code
class SynthesizeStream(tts.SynthesizeStream): """Stream text into a session and receive audio as it is rendered. Bland accumulates the text deltas server-side and picks its own synthesis boundaries, so tokens go out as they arrive: no sentence tokenizer, no character threshold, and no flush after every fragment. """ def __init__(self, *, tts: TTS, conn_options: APIConnectOptions) -> None: super().__init__(tts=tts, conn_options=conn_options) self._tts: TTS = tts # `TTS.stream()` refuses to build one of these without a pool, so this is # always present — bound once here rather than re-narrowed at each use. assert tts._pool is not None self._pool = tts._pool self._opts = replace(tts._opts) async def _run(self, output_emitter: tts.AudioEmitter) -> None: # One stream is one segment, which is one Bland turn: the socket carries a # single turn at a time and the framework opens a stream per segment. context_id = utils.shortuuid() output_emitter.initialize( request_id=context_id, sample_rate=self._opts.sample_rate, num_channels=NUM_CHANNELS, mime_type="audio/pcm", stream=True, ) output_emitter.start_segment(segment_id=context_id) input_sent = asyncio.Event() text_sent = False async def send_task(ws: aiohttp.ClientWebSocketResponse) -> None: nonlocal text_sent async for data in self._input_ch: if isinstance(data, self._FlushSentinel): continue text_sent = True self._mark_started() await ws.send_str( json.dumps({"type": "speak", "context_id": context_id, "text": data}) ) input_sent.set() if not text_sent: output_emitter.end_segment() input_sent.set() return # Bland holds a short tail back waiting for more context; this releases it. await ws.send_str(json.dumps({"type": "end_of_turn", "context_id": context_id})) input_sent.set() async def recv_task(ws: aiohttp.ClientWebSocketResponse) -> None: await input_sent.wait() if not text_sent: return while True: msg = await ws.receive(timeout=self._conn_options.timeout) if msg.type in ( aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING, ): raise APIStatusError( "Bland connection closed unexpectedly", status_code=ws.close_code or -1, body=f"{msg.data=} {msg.extra=}", ) if msg.type is aiohttp.WSMsgType.BINARY: output_emitter.push(msg.data) continue if msg.type is not aiohttp.WSMsgType.TEXT: logger.warning("unexpected Bland message type %s", msg.type) continue data = json.loads(msg.data) event = data.get("type") if event == "utterance_end": # A stale terminator can only belong to a turn this stream already # abandoned, so it is not this turn's boundary. if data.get("context_id") != context_id: continue if (reason := data.get("reason")) != "complete": raise APIError(f"Bland turn ended as {reason}", body=data) output_emitter.end_segment() return elif event == "utterance_start": continue elif event == "error": raise _api_error(data) else: logger.warning("unexpected Bland message %s", data) async def cancel_and_drain(ws: aiohttp.ClientWebSocketResponse) -> None: """Cancel the active turn and consume its terminal event before reuse.""" await ws.send_str(json.dumps({"type": "cancel", "context_id": context_id})) while True: msg = await ws.receive() if msg.type in ( aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING, ): raise APIStatusError( "Bland connection closed while cancelling a turn", status_code=ws.close_code or -1, body=f"{msg.data=} {msg.extra=}", ) if msg.type is aiohttp.WSMsgType.BINARY: continue if msg.type is not aiohttp.WSMsgType.TEXT: continue data = json.loads(msg.data) if data.get("context_id") != context_id: continue if data.get("type") == "utterance_end": if data.get("reason") in ("cancelled", "complete"): return raise APIError("Bland did not cancel the turn cleanly", body=data) if data.get("type") == "error": # An admission failure creates no turn and emits no terminal, so # there is nothing left to drain to. Returning here would hand the # socket back reusable — but from this side an admission refusal # is indistinguishable from a mid-turn error whose terminal is # still in flight, and that terminal would surface against the # next, unrelated turn. Raising closes the socket, which costs one # reconnect on a path that has already failed and keeps turns from # contaminating each other. raise _api_error(data) cancelled: asyncio.CancelledError | None = None try: async with self._pool.connection(timeout=self._conn_options.timeout) as ws: self._acquire_time = self._pool.last_acquire_time self._connection_reused = self._pool.last_connection_reused tasks = [ asyncio.create_task(send_task(ws)), asyncio.create_task(recv_task(ws)), ] try: await asyncio.gather(*tasks) except asyncio.CancelledError as e: turn_was_sent = input_sent.is_set() input_sent.set() await utils.aio.gracefully_cancel(*tasks) if text_sent and not turn_was_sent: # Cancellation interrupted the first write, so whether the # server owns this context is unknowable. Do not reuse it. try: await ws.close() except Exception: pass raise if text_sent: try: # Bounded: a socket that does not answer must not hold up # the barge-in that is waiting on this teardown. await asyncio.wait_for( cancel_and_drain(ws), timeout=_CANCEL_DRAIN_TIMEOUT ) except asyncio.CancelledError: try: await ws.close() except Exception: pass raise except BaseException as drain_error: # Tidying up failed, but this is still a cancellation, and # it has to leave as one. Letting the drain's own error # escape would turn a barge-in into a retryable API error: # the framework replays the buffered text, so the caller # hears the interrupted sentence a second time — and when # the cancel came from `aclose()` before `end_input()`, the # replay waits forever on an input channel nothing will # close, with the one cancellation already spent. logger.debug( "Bland cancel handshake failed", extra={"error": str(drain_error)}, ) try: await ws.close() except Exception: pass raise e from drain_error # Exit the pool context normally so this clean, drained session is # returned for the next turn, then preserve caller cancellation. cancelled = e except BaseException: # A failed stream cannot safely return a socket with unread turn # state to the pool. try: await ws.close() except Exception: pass raise finally: input_sent.set() await utils.aio.gracefully_cancel(*tasks) if cancelled is not None: raise cancelled except asyncio.TimeoutError: raise APITimeoutError() from None except APIError: raise except Exception as e: raise APIConnectionError() from eStream text into a session and receive audio as it is rendered.
Bland accumulates the text deltas server-side and picks its own synthesis boundaries, so tokens go out as they arrive: no sentence tokenizer, no character threshold, and no flush after every fragment.
Ancestors
- livekit.agents.tts.tts.SynthesizeStream
- abc.ABC
class TTS (*,
voice_id: str = '2f29fdbb-c55e-4add-9c7c-93437ebf379d',
sample_rate: int = 48000,
expressiveness: NotGivenOr[float] = NOT_GIVEN,
stability: NotGivenOr[float] = NOT_GIVEN,
api_key: NotGivenOr[str] = NOT_GIVEN,
base_url: str = 'https://api.bland.ai/v2',
streaming: bool = True,
http_session: aiohttp.ClientSession | None = None)-
Expand source code
class TTS(tts.TTS): def __init__( self, *, voice_id: str = DEFAULT_VOICE_ID, sample_rate: int = DEFAULT_SAMPLE_RATE, expressiveness: NotGivenOr[float] = NOT_GIVEN, stability: NotGivenOr[float] = NOT_GIVEN, api_key: NotGivenOr[str] = NOT_GIVEN, base_url: str = DEFAULT_BASE_URL, streaming: bool = True, http_session: aiohttp.ClientSession | None = None, ) -> None: """Create a new instance of the Bland TTS. Args: voice_id: Bland voice UUID; names are not accepted. Defaults to a ``BTTS_V3`` voice. ``BTTS_V2`` voices work, but the controls below are calibrated for ``BTTS_V3``. sample_rate: Output sample rate in Hz, one of 8000, 16000, 24000, 44100, 48000. Defaults to 48000, the rate ``BTTS_V3`` renders natively. expressiveness: 0.0-1.0. Higher is more varied intonation. stability: 0.0-1.0. Higher is more consistent between renders. api_key: Bland API key. Falls back to the ``BLAND_API_KEY`` environment variable. base_url: Override the Bland API base URL. streaming: Stream text into one realtime WebSocket session, which is what a voice agent wants: audio starts before the sentence is finished, and a barge-in cancels the turn in place. Set False to synthesize each utterance with a single HTTP request instead — no session is held open, and no concurrency slot with it, which suits a pipeline that speaks rarely. ``synthesize()`` uses HTTP either way. http_session: Optional ``aiohttp.ClientSession`` to reuse. """ if sample_rate not in SAMPLE_RATES: raise ValueError(f"sample_rate must be one of {SAMPLE_RATES}, got {sample_rate}") super().__init__( capabilities=tts.TTSCapabilities(streaming=streaming), sample_rate=sample_rate, num_channels=NUM_CHANNELS, ) bland_api_key = api_key if is_given(api_key) else os.environ.get("BLAND_API_KEY") if not bland_api_key: raise ValueError( "Bland API key is required, either as `api_key` argument or " "`BLAND_API_KEY` environment variable" ) self._api_key = bland_api_key self._opts = _TTSOptions( voice_id=voice_id, sample_rate=sample_rate, expressiveness=expressiveness, stability=stability, base_url=base_url.rstrip("/"), ) self._session = http_session self._streams = weakref.WeakSet[SynthesizeStream]() # No pool when streaming is off: nothing would ever check a socket out of it, # and an open session holds a concurrency slot for as long as it lives. self._pool = ( utils.ConnectionPool[aiohttp.ClientWebSocketResponse]( connect_cb=self._connect_ws, close_cb=self._close_ws, max_session_duration=_MAX_SESSION_DURATION, mark_refreshed_on_get=True, ) if streaming else None ) @property def provider(self) -> str: return "Bland" def _ensure_session(self) -> aiohttp.ClientSession: if not self._session: self._session = utils.http_context.http_session() return self._session async def _connect_ws(self, timeout: float) -> aiohttp.ClientWebSocketResponse: """Open a session and hold it at `ready`, so a turn starts on the first `speak`.""" try: ws = await asyncio.wait_for( self._ensure_session().ws_connect( _ws_url(self._opts.base_url), headers={"Authorization": f"Bearer {self._api_key}"}, ), timeout, ) except aiohttp.WSServerHandshakeError as e: # Bland answers an upgrade carrying no credential with a real 401 rather # than accepting and closing, so this is the path a bad key takes. raise APIStatusError( message=e.message, status_code=e.status, request_id=e.headers.get("x-request-id") if e.headers else None, ) from e try: init: dict[str, Any] = { "type": "init", "voice": self._opts.voice_id, "audio": {"encoding": "pcm_s16le", "sample_rate": self._opts.sample_rate}, } if controls := _controls(self._opts): init["controls"] = controls await ws.send_str(json.dumps(init)) msg = await asyncio.wait_for(ws.receive(), timeout) if msg.type is not aiohttp.WSMsgType.TEXT: raise APIError(f"Bland did not acknowledge init: {msg.type}") data = json.loads(msg.data) if data.get("type") != "ready": raise _api_error(data) if ( data.get("encoding") != "pcm_s16le" or data.get("sample_rate") != self._opts.sample_rate ): raise APIError( "Bland acknowledged an unexpected audio format", body=data, retryable=False, ) except BaseException: try: await ws.close() except Exception: pass raise logger.debug("Bland TTS session ready", extra={"session_id": data.get("session_id")}) return ws async def _close_ws(self, ws: aiohttp.ClientWebSocketResponse) -> None: """Settle the session rather than dropping the socket, so usage is reconciled once.""" if ws.closed: return try: await ws.send_str(json.dumps({"type": "close"})) await asyncio.wait_for(ws.receive(), timeout=1.0) except Exception as e: logger.debug("Bland TTS close handshake skipped", extra={"error": str(e)}) finally: await ws.close() def update_options( self, *, voice_id: NotGivenOr[str] = NOT_GIVEN, expressiveness: NotGivenOr[float] = NOT_GIVEN, stability: NotGivenOr[float] = NOT_GIVEN, ) -> None: changed = False if is_given(voice_id): self._opts.voice_id = voice_id changed = True if is_given(expressiveness): self._opts.expressiveness = expressiveness changed = True if is_given(stability): self._opts.stability = stability changed = True if changed: # `init` fixes the voice and controls for the life of a session, so a pooled # socket would keep serving the old ones. if self._pool is not None: self._pool.invalidate() def synthesize( self, text: str, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS ) -> ChunkedStream: return ChunkedStream(tts=self, input_text=text, conn_options=conn_options) def stream( self, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS ) -> SynthesizeStream: if self._pool is None: raise RuntimeError( "streaming is disabled on this Bland TTS instance; construct it with " "`streaming=True`, or wrap it in a `tts.StreamAdapter`" ) stream = SynthesizeStream(tts=self, conn_options=conn_options) self._streams.add(stream) return stream def prewarm(self) -> None: if self._pool is not None: self._pool.prewarm() async def aclose(self) -> None: for stream in list(self._streams): await stream.aclose() self._streams.clear() if self._pool is not None: await self._pool.aclose()Helper class that provides a standard way to create an ABC using inheritance.
Create a new instance of the Bland TTS.
Args
voice_id- Bland voice UUID; names are not accepted. Defaults to a
BTTS_V3voice.BTTS_V2voices work, but the controls below are calibrated forBTTS_V3. sample_rate- Output sample rate in Hz, one of 8000, 16000, 24000, 44100, 48000.
Defaults to 48000, the rate
BTTS_V3renders natively. expressiveness- 0.0-1.0. Higher is more varied intonation.
stability- 0.0-1.0. Higher is more consistent between renders.
api_key- Bland API key. Falls back to the
BLAND_API_KEYenvironment variable. base_url- Override the Bland API base URL.
streaming- Stream text into one realtime WebSocket session, which is what a
voice agent wants: audio starts before the sentence is finished, and a
barge-in cancels the turn in place. Set False to synthesize each
utterance with a single HTTP request instead — no session is held open,
and no concurrency slot with it, which suits a pipeline that speaks
rarely.
synthesize()uses HTTP either way. http_session- Optional
aiohttp.ClientSessionto reuse.
Ancestors
- livekit.agents.tts.tts.TTS
- abc.ABC
- EventEmitter
- typing.Generic
Instance variables
prop provider : str-
Expand source code
@property def provider(self) -> str: return "Bland"Get the provider name/identifier for this TTS instance.
Returns
The provider name if available, "unknown" otherwise.
Note
Plugins should override this property to provide their provider information.
Methods
async def aclose(self) ‑> None-
Expand source code
async def aclose(self) -> None: for stream in list(self._streams): await stream.aclose() self._streams.clear() if self._pool is not None: await self._pool.aclose() def prewarm(self) ‑> None-
Expand source code
def prewarm(self) -> None: if self._pool is not None: self._pool.prewarm()Pre-warm connection to the TTS service
def stream(self,
*,
conn_options: APIConnectOptions = APIConnectOptions(max_retry=3, retry_interval=2.0, timeout=10.0)) ‑> livekit.plugins.bland.tts.SynthesizeStream-
Expand source code
def stream( self, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS ) -> SynthesizeStream: if self._pool is None: raise RuntimeError( "streaming is disabled on this Bland TTS instance; construct it with " "`streaming=True`, or wrap it in a `tts.StreamAdapter`" ) stream = SynthesizeStream(tts=self, conn_options=conn_options) self._streams.add(stream) return stream def synthesize(self,
text: str,
*,
conn_options: APIConnectOptions = APIConnectOptions(max_retry=3, retry_interval=2.0, timeout=10.0)) ‑> livekit.plugins.bland.tts.ChunkedStream-
Expand source code
def synthesize( self, text: str, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS ) -> ChunkedStream: return ChunkedStream(tts=self, input_text=text, conn_options=conn_options) def update_options(self,
*,
voice_id: NotGivenOr[str] = NOT_GIVEN,
expressiveness: NotGivenOr[float] = NOT_GIVEN,
stability: NotGivenOr[float] = NOT_GIVEN) ‑> None-
Expand source code
def update_options( self, *, voice_id: NotGivenOr[str] = NOT_GIVEN, expressiveness: NotGivenOr[float] = NOT_GIVEN, stability: NotGivenOr[float] = NOT_GIVEN, ) -> None: changed = False if is_given(voice_id): self._opts.voice_id = voice_id changed = True if is_given(expressiveness): self._opts.expressiveness = expressiveness changed = True if is_given(stability): self._opts.stability = stability changed = True if changed: # `init` fixes the voice and controls for the life of a session, so a pooled # socket would keep serving the old ones. if self._pool is not None: self._pool.invalidate()
Inherited members