livekit-plugins-kalpa 1.8.11__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,50 @@
1
+ Metadata-Version: 2.5
2
+ Name: livekit-plugins-kalpa
3
+ Version: 1.8.11
4
+ Summary: Agent Framework plugin for Kalpa text-to-speech.
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: ai,audio,kalpa,livekit,realtime,text-to-speech,tts,voice
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: Topic :: Multimedia :: Sound/Audio
17
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
18
+ Requires-Python: >=3.10.0
19
+ Requires-Dist: livekit-agents>=1.8.11
20
+ Description-Content-Type: text/markdown
21
+
22
+ # Kalpa plugin for LiveKit Agents
23
+
24
+ Support for text-to-speech with [Kalpa](https://kalpalabs.ai/) in LiveKit Agents.
25
+
26
+ See the [Kalpa integration docs](https://docs.livekit.io/agents/models/tts/kalpa/) for more information, and the [Kalpa docs](https://docs.kalpalabs.ai/text-to-speech) for the API, models and voices.
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ pip install livekit-plugins-kalpa
32
+ ```
33
+
34
+ ## Pre-requisites
35
+
36
+ You'll need an API key from Kalpa. It can be set as an environment variable: `KALPA_API_KEY`. You can create one [here](https://kalpalabs.ai/dashboard/keys/).
37
+
38
+ ## Usage
39
+
40
+ ```python
41
+ from livekit.agents import AgentSession
42
+ from livekit.plugins import kalpa
43
+
44
+ session = AgentSession(
45
+ tts=kalpa.TTS(voice="18c26f49-9f9c-400c-bec9-7467f28e1db5"), # Arjun
46
+ # ... stt, llm, etc.
47
+ )
48
+ ```
49
+
50
+ `kalpa-tts-1` speaks English and Hindi. List the voices with `GET https://api.kalpalabs.ai/v1/voices`.
@@ -0,0 +1,29 @@
1
+ # Kalpa plugin for LiveKit Agents
2
+
3
+ Support for text-to-speech with [Kalpa](https://kalpalabs.ai/) in LiveKit Agents.
4
+
5
+ See the [Kalpa integration docs](https://docs.livekit.io/agents/models/tts/kalpa/) for more information, and the [Kalpa docs](https://docs.kalpalabs.ai/text-to-speech) for the API, models and voices.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install livekit-plugins-kalpa
11
+ ```
12
+
13
+ ## Pre-requisites
14
+
15
+ You'll need an API key from Kalpa. It can be set as an environment variable: `KALPA_API_KEY`. You can create one [here](https://kalpalabs.ai/dashboard/keys/).
16
+
17
+ ## Usage
18
+
19
+ ```python
20
+ from livekit.agents import AgentSession
21
+ from livekit.plugins import kalpa
22
+
23
+ session = AgentSession(
24
+ tts=kalpa.TTS(voice="18c26f49-9f9c-400c-bec9-7467f28e1db5"), # Arjun
25
+ # ... stt, llm, etc.
26
+ )
27
+ ```
28
+
29
+ `kalpa-tts-1` speaks English and Hindi. List the voices with `GET https://api.kalpalabs.ai/v1/voices`.
@@ -0,0 +1,47 @@
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
+ """Kalpa plugin for LiveKit Agents
16
+
17
+ Support for text-to-speech with [Kalpa](https://kalpalabs.ai/).
18
+
19
+ See https://docs.kalpalabs.ai/text-to-speech for more information.
20
+ """
21
+
22
+ from .models import TTSModels
23
+ from .tts import TTS, ChunkedStream, SynthesizeStream
24
+ from .version import __version__
25
+
26
+ __all__ = ["TTS", "ChunkedStream", "SynthesizeStream", "TTSModels", "__version__"]
27
+
28
+ from livekit.agents import Plugin
29
+
30
+ from .log import logger
31
+
32
+
33
+ class KalpaPlugin(Plugin):
34
+ def __init__(self) -> None:
35
+ super().__init__(__name__, __version__, __package__, logger)
36
+
37
+
38
+ Plugin.register_plugin(KalpaPlugin())
39
+
40
+ # Cleanup docs of unexported modules
41
+ _module = dir()
42
+ NOT_IN_ALL = [m for m in _module if m not in __all__]
43
+
44
+ __pdoc__ = {}
45
+
46
+ for n in NOT_IN_ALL:
47
+ __pdoc__[n] = False
@@ -0,0 +1,3 @@
1
+ import logging
2
+
3
+ logger = logging.getLogger("livekit.plugins.kalpa")
@@ -0,0 +1,3 @@
1
+ from typing import Literal
2
+
3
+ TTSModels = Literal["kalpa-tts-1"]
@@ -0,0 +1,426 @@
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 base64
19
+ import contextlib
20
+ import json
21
+ import os
22
+ import time
23
+ import weakref
24
+ from collections.abc import Coroutine
25
+ from dataclasses import dataclass, replace
26
+ from typing import Any
27
+
28
+ import aiohttp
29
+
30
+ from livekit.agents import (
31
+ APIConnectionError,
32
+ APIConnectOptions,
33
+ APIError,
34
+ APIStatusError,
35
+ APITimeoutError,
36
+ tts,
37
+ utils,
38
+ )
39
+ from livekit.agents.types import DEFAULT_API_CONNECT_OPTIONS, NOT_GIVEN, NotGivenOr
40
+ from livekit.agents.utils import is_given
41
+
42
+ from .models import TTSModels
43
+
44
+ BASE_URL = "https://api.kalpalabs.ai"
45
+ DEFAULT_VOICE = "18c26f49-9f9c-400c-bec9-7467f28e1db5" # Arjun
46
+ # LiveKit resamples to the pipeline's rate, so the plugin always requests this one
47
+ SAMPLE_RATE = 24000
48
+ NUM_CHANNELS = 1
49
+ # Pooled sockets retire before the API's idle timeout closes them.
50
+ MAX_IDLE_SECONDS = 240
51
+ # How long an interrupted response may take to acknowledge its cancel before its socket is dropped.
52
+ CANCEL_ACK_TIMEOUT = 1.0
53
+ CLOSE_TIMEOUT = 1.0
54
+
55
+ # HTTP status for each error type, as documented at https://docs.kalpalabs.ai/rate-limits-and-errors
56
+ _ERROR_STATUS = {
57
+ "invalid_request": 400,
58
+ "context_length_exceeded": 400,
59
+ "authentication_error": 401,
60
+ "allowance_exhausted": 402,
61
+ "account_suspended": 403,
62
+ "not_found": 404,
63
+ "rate_limit_exceeded": 429,
64
+ "internal_error": 500,
65
+ "inference_error": 502,
66
+ "overloaded": 503,
67
+ "service_unavailable": 503,
68
+ }
69
+
70
+
71
+ @dataclass
72
+ class _TTSOptions:
73
+ model: TTSModels | str
74
+ voice: str
75
+ api_key: str
76
+ base_url: str
77
+
78
+
79
+ class TTS(tts.TTS):
80
+ def __init__(
81
+ self,
82
+ *,
83
+ voice: str = DEFAULT_VOICE,
84
+ model: TTSModels | str = "kalpa-tts-1",
85
+ api_key: str | None = None,
86
+ base_url: str = BASE_URL,
87
+ http_session: aiohttp.ClientSession | None = None,
88
+ ) -> None:
89
+ """
90
+ Create a new instance of Kalpa TTS.
91
+
92
+ Args:
93
+ voice (str): Voice id, from `GET /v1/voices`. Defaults to Arjun.
94
+ model (TTSModels | str): TTS model. Defaults to "kalpa-tts-1".
95
+ api_key (str): Kalpa API key. Defaults to the KALPA_API_KEY environment variable.
96
+ base_url (str): API base URL. Defaults to "https://api.kalpalabs.ai".
97
+ http_session (aiohttp.ClientSession): Optional aiohttp session to use for requests.
98
+ """
99
+ super().__init__(
100
+ capabilities=tts.TTSCapabilities(streaming=True),
101
+ sample_rate=SAMPLE_RATE,
102
+ num_channels=NUM_CHANNELS,
103
+ )
104
+
105
+ api_key = api_key or os.environ.get("KALPA_API_KEY")
106
+ if not api_key:
107
+ raise ValueError("Kalpa API key required. Set KALPA_API_KEY or provide api_key.")
108
+
109
+ self._opts = _TTSOptions(
110
+ model=model, voice=voice, api_key=api_key, base_url=base_url.rstrip("/")
111
+ )
112
+ self._session = http_session
113
+ self._streams = weakref.WeakSet[SynthesizeStream]()
114
+ self._tasks: set[asyncio.Task[Any]] = set()
115
+ self._pool = utils.ConnectionPool[aiohttp.ClientWebSocketResponse](
116
+ connect_cb=self._connect_ws,
117
+ close_cb=self._close_ws,
118
+ max_session_duration=MAX_IDLE_SECONDS,
119
+ mark_refreshed_on_get=True,
120
+ )
121
+
122
+ @property
123
+ def model(self) -> str:
124
+ return self._opts.model
125
+
126
+ @property
127
+ def provider(self) -> str:
128
+ return "Kalpa"
129
+
130
+ async def _connect_ws(self, timeout: float) -> aiohttp.ClientWebSocketResponse:
131
+ url = f"{self._opts.base_url.replace('http', 'ws', 1)}/v1/tts/{self._opts.voice}/stream"
132
+ ws = await asyncio.wait_for(self._ensure_session().ws_connect(url), timeout)
133
+ try:
134
+ await ws.send_str(
135
+ json.dumps(
136
+ {
137
+ "type": "initializeConnection",
138
+ "api_key": self._opts.api_key,
139
+ "model": self._opts.model,
140
+ "sample_rate": SAMPLE_RATE,
141
+ # audio starts at the first complete sentences, before the reply's flush
142
+ "generation_config": {},
143
+ }
144
+ )
145
+ )
146
+ event = await _receive_event(ws, timeout)
147
+ if event["type"] != "sessionCreated":
148
+ raise APIError(f"unexpected Kalpa message before sessionCreated: {event['type']}")
149
+ except BaseException:
150
+ await self._close_ws(ws)
151
+ raise
152
+ return ws
153
+
154
+ async def _close_ws(self, ws: aiohttp.ClientWebSocketResponse) -> None:
155
+ # a half-open peer never answers the closing handshake; cancelling drops the connection
156
+ with contextlib.suppress(asyncio.TimeoutError):
157
+ await asyncio.wait_for(ws.close(), CLOSE_TIMEOUT)
158
+
159
+ def _recycle(self, ws: aiohttp.ClientWebSocketResponse) -> None:
160
+ """Cancel an interrupted response in the background and return its socket to the pool once
161
+ the server acknowledges the cancel; without a timely acknowledgement, replace the socket."""
162
+
163
+ async def _cancel() -> None:
164
+ try:
165
+ await asyncio.wait_for(_cancel_response(ws), CANCEL_ACK_TIMEOUT)
166
+ except Exception:
167
+ self._drop(ws)
168
+ self._pool.prewarm()
169
+ else:
170
+ self._pool.put(ws)
171
+
172
+ self._spawn(_cancel())
173
+
174
+ def _drop(self, ws: aiohttp.ClientWebSocketResponse) -> None:
175
+ """Take a socket out of the pool and close it now, so the server stops any work for it."""
176
+ self._pool.remove(ws)
177
+ self._spawn(self._close_ws(ws))
178
+
179
+ def _spawn(self, coro: Coroutine[Any, Any, Any]) -> None:
180
+ task = asyncio.create_task(coro)
181
+ self._tasks.add(task)
182
+ task.add_done_callback(self._tasks.discard)
183
+
184
+ def _ensure_session(self) -> aiohttp.ClientSession:
185
+ if not self._session:
186
+ self._session = utils.http_context.http_session()
187
+ return self._session
188
+
189
+ def update_options(
190
+ self,
191
+ *,
192
+ voice: NotGivenOr[str] = NOT_GIVEN,
193
+ model: NotGivenOr[TTSModels | str] = NOT_GIVEN,
194
+ ) -> None:
195
+ """
196
+ Update the voice or model. Replies that already hold a connection finish with the old
197
+ ones; every other reply, including one still connecting, uses the new ones.
198
+
199
+ Args:
200
+ voice (str): Voice id, from `GET /v1/voices`.
201
+ model (TTSModels | str): TTS model.
202
+ """
203
+ if is_given(voice):
204
+ self._opts.voice = voice
205
+ if is_given(model):
206
+ self._opts.model = model
207
+ if is_given(voice) or is_given(model):
208
+ self._pool.invalidate()
209
+
210
+ def synthesize(
211
+ self, text: str, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
212
+ ) -> ChunkedStream:
213
+ return ChunkedStream(tts=self, input_text=text, conn_options=conn_options)
214
+
215
+ def stream(
216
+ self, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
217
+ ) -> SynthesizeStream:
218
+ stream = SynthesizeStream(tts=self, conn_options=conn_options)
219
+ self._streams.add(stream)
220
+ return stream
221
+
222
+ def prewarm(self) -> None:
223
+ self._pool.prewarm()
224
+
225
+ async def aclose(self) -> None:
226
+ for stream in list(self._streams):
227
+ await stream.aclose()
228
+ self._streams.clear()
229
+ await utils.aio.cancel_and_wait(*self._tasks)
230
+ await self._pool.aclose()
231
+
232
+
233
+ class ChunkedStream(tts.ChunkedStream):
234
+ """Synthesizes the whole text in one `POST /v1/tts/{voice_id}/stream`, which returns raw PCM
235
+ as it is generated."""
236
+
237
+ def __init__(self, *, tts: TTS, input_text: str, conn_options: APIConnectOptions) -> None:
238
+ super().__init__(tts=tts, input_text=input_text, conn_options=conn_options)
239
+ self._tts: TTS = tts
240
+ self._opts = replace(tts._opts)
241
+
242
+ async def _run(self, output_emitter: tts.AudioEmitter) -> None:
243
+ output_emitter.initialize(
244
+ request_id=utils.shortuuid(),
245
+ sample_rate=SAMPLE_RATE,
246
+ num_channels=NUM_CHANNELS,
247
+ mime_type="audio/pcm",
248
+ )
249
+ if not self._input_text.strip():
250
+ return # the API rejects blank text, and there is nothing to say
251
+
252
+ try:
253
+ async with self._tts._ensure_session().post(
254
+ f"{self._opts.base_url}/v1/tts/{self._opts.voice}/stream",
255
+ headers={"Authorization": f"Bearer {self._opts.api_key}"},
256
+ json={
257
+ "text": self._input_text,
258
+ "model": self._opts.model,
259
+ "sample_rate": SAMPLE_RATE,
260
+ },
261
+ timeout=aiohttp.ClientTimeout(
262
+ sock_connect=self._conn_options.timeout, sock_read=self._conn_options.timeout
263
+ ),
264
+ ) as resp:
265
+ if resp.status != 200:
266
+ try:
267
+ error = (await resp.json(content_type=None))["error"]
268
+ except Exception:
269
+ error = {}
270
+ raise _status_error(error, status=resp.status)
271
+
272
+ output_emitter._note_provider_request_id(resp.headers.get("X-Request-ID", ""))
273
+ async for data, _ in resp.content.iter_chunks():
274
+ output_emitter.push(data)
275
+ output_emitter.flush()
276
+ except asyncio.TimeoutError:
277
+ raise APITimeoutError() from None
278
+ except APIError:
279
+ raise
280
+ except Exception as e:
281
+ raise APIConnectionError() from e
282
+
283
+
284
+ class SynthesizeStream(tts.SynthesizeStream):
285
+ """Streams one reply's text over a pooled `WSS /v1/tts/{voice_id}/stream` connection and
286
+ receives its audio while the text is still arriving."""
287
+
288
+ def __init__(self, *, tts: TTS, conn_options: APIConnectOptions) -> None:
289
+ super().__init__(tts=tts, conn_options=conn_options)
290
+ self._tts: TTS = tts
291
+
292
+ async def _run(self, output_emitter: tts.AudioEmitter) -> None:
293
+ request_id = utils.shortuuid()
294
+ output_emitter.initialize(
295
+ request_id=request_id,
296
+ sample_rate=SAMPLE_RATE,
297
+ num_channels=NUM_CHANNELS,
298
+ mime_type="audio/pcm",
299
+ stream=True,
300
+ )
301
+ output_emitter.start_segment(segment_id=request_id)
302
+
303
+ try:
304
+ await self._run_ws(output_emitter)
305
+ except asyncio.TimeoutError:
306
+ raise APITimeoutError() from None
307
+ except aiohttp.WSServerHandshakeError as e:
308
+ raise APIStatusError(e.message, status_code=e.status) from None
309
+ except APIError:
310
+ raise
311
+ except Exception as e:
312
+ raise APIConnectionError() from e
313
+
314
+ output_emitter.end_segment()
315
+
316
+ async def _run_ws(self, output_emitter: tts.AudioEmitter) -> None:
317
+ pool = self._tts._pool
318
+ ws: aiohttp.ClientWebSocketResponse | None = None
319
+ sent = False # the server holds text or a response from this reply
320
+ done = False # the reply's response finished
321
+ rejected = False # the server refused one of the reply's messages but kept the connection
322
+ # the server must answer within the timeout of the flush, and of every event after it;
323
+ # before the flush, audio may legitimately pause while the reply's text is still arriving
324
+ deadline: float | None = None
325
+
326
+ async def _send_task(ws: aiohttp.ClientWebSocketResponse) -> None:
327
+ nonlocal sent, deadline
328
+ async for data in self._input_ch:
329
+ if isinstance(data, self._FlushSentinel):
330
+ if sent:
331
+ break
332
+ continue # a flush before any text ends nothing
333
+ if data.strip():
334
+ self._mark_started()
335
+ sent = True
336
+ await ws.send_str(json.dumps({"type": "sendText", "text": data}))
337
+
338
+ sent = True
339
+ await ws.send_str(json.dumps({"type": "sendText", "flush": True}))
340
+ deadline = time.monotonic() + self._conn_options.timeout
341
+
342
+ async def _recv_task(ws: aiohttp.ClientWebSocketResponse) -> None:
343
+ nonlocal done, rejected, deadline
344
+ error: dict[str, Any] | None = None
345
+ while True:
346
+ timeout = self._conn_options.timeout
347
+ if deadline is not None:
348
+ timeout = max(deadline - time.monotonic(), 0.001)
349
+ try:
350
+ event = await _receive_event(ws, timeout)
351
+ except asyncio.TimeoutError:
352
+ if deadline is not None and time.monotonic() >= deadline:
353
+ raise
354
+ continue
355
+ if deadline is not None:
356
+ deadline = time.monotonic() + self._conn_options.timeout
357
+
358
+ if event["type"] == "error": # non-fatal: the connection stays open
359
+ if not event.get("response_id"):
360
+ rejected = True # a refused sendText: the reply's text is incomplete
361
+ raise _status_error(event["error"])
362
+ error = event["error"] # the response failed; its responseDone follows
363
+ elif event["type"] == "responseCreated":
364
+ output_emitter._note_provider_request_id(event["response_id"])
365
+ elif event["type"] == "responseAudio":
366
+ output_emitter.push(base64.b64decode(event["pcm_b64"]))
367
+ elif event["type"] == "responseDone":
368
+ done = True
369
+ if event["status"] != "completed":
370
+ if error:
371
+ raise _status_error(error)
372
+ raise APIError(f"Kalpa response ended with status {event['status']}")
373
+ return
374
+
375
+ try:
376
+ ws = await pool.get(timeout=self._conn_options.timeout)
377
+ self._acquire_time = pool.last_acquire_time
378
+ self._connection_reused = pool.last_connection_reused
379
+ tasks = [
380
+ asyncio.create_task(_send_task(ws)),
381
+ asyncio.create_task(_recv_task(ws)),
382
+ ]
383
+ try:
384
+ await asyncio.gather(*tasks)
385
+ finally:
386
+ await utils.aio.gracefully_cancel(*tasks)
387
+ except BaseException as e:
388
+ if ws is not None:
389
+ # an interrupted reply, or a non-fatal error, leaves the socket usable once the
390
+ # server holds nothing more of this reply
391
+ if isinstance(e, asyncio.CancelledError) or done or rejected:
392
+ if sent and not done:
393
+ self._tts._recycle(ws)
394
+ else:
395
+ pool.put(ws)
396
+ else:
397
+ self._tts._drop(ws)
398
+ raise
399
+
400
+ pool.put(ws)
401
+
402
+
403
+ async def _receive_event(ws: aiohttp.ClientWebSocketResponse, timeout: float | None) -> Any:
404
+ msg = await ws.receive(timeout=timeout)
405
+ if msg.type != aiohttp.WSMsgType.TEXT:
406
+ raise APIConnectionError(f"Kalpa connection closed (code {ws.close_code})")
407
+ event = json.loads(msg.data)
408
+ if event["type"] == "error" and event.get("fatal", True):
409
+ raise _status_error(event["error"])
410
+ return event
411
+
412
+
413
+ async def _cancel_response(ws: aiohttp.ClientWebSocketResponse) -> None:
414
+ await ws.send_str(json.dumps({"type": "cancelResponse"}))
415
+ while (await _receive_event(ws, None))["type"] != "cancelled":
416
+ pass
417
+
418
+
419
+ def _status_error(error: dict[str, Any], *, status: int | None = None) -> APIStatusError:
420
+ error_type = error.get("type")
421
+ return APIStatusError(
422
+ error.get("message") or "Kalpa API error",
423
+ status_code=status or _ERROR_STATUS.get(error_type or "", 500),
424
+ request_id=error.get("request_id"),
425
+ body=error_type,
426
+ )
@@ -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.11"
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "livekit-plugins-kalpa"
7
+ dynamic = ["version"]
8
+ description = "Agent Framework plugin for Kalpa text-to-speech."
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 = ["voice", "ai", "realtime", "audio", "livekit", "kalpa", "tts", "text-to-speech"]
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 :: Only",
22
+ ]
23
+ dependencies = ["livekit-agents>=1.8.11"]
24
+
25
+ [project.urls]
26
+ Documentation = "https://docs.livekit.io"
27
+ Website = "https://livekit.io/"
28
+ Source = "https://github.com/livekit/agents"
29
+
30
+ [tool.hatch.version]
31
+ path = "livekit/plugins/kalpa/version.py"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["livekit"]
35
+
36
+ [tool.hatch.build.targets.sdist]
37
+ include = ["/livekit"]
38
+
39
+ [tool.uv]
40
+ exclude-newer = "7 days"
41
+ exclude-newer-package = { livekit-agents = "0 days" }