livekit-plugins-clevrlabs 0.1.0__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,21 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+
9
+ # Virtual environments
10
+ .venv/
11
+ venv/
12
+
13
+ # Tooling caches
14
+ .ruff_cache/
15
+ .mypy_cache/
16
+ .pytest_cache/
17
+
18
+ # OS / editor
19
+ .DS_Store
20
+ .idea/
21
+ .vscode/
@@ -0,0 +1,11 @@
1
+ Copyright (c) 2026 Clevr Labs. All rights reserved.
2
+
3
+ This software and associated documentation files (the "Software") are the
4
+ proprietary and confidential property of Clevr Labs. Unauthorized copying,
5
+ distribution, modification, or use of the Software, in whole or in part,
6
+ is strictly prohibited without the express prior written permission of
7
+ Clevr Labs.
8
+
9
+ The Software is provided to authorized users solely for use with the
10
+ Clevr Labs API service. No license, express or implied, is granted to any
11
+ intellectual property rights in the Software.
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: livekit-plugins-clevrlabs
3
+ Version: 0.1.0
4
+ Summary: LiveKit Agents TTS plugin for the Clevr Labs conversational speech model
5
+ Project-URL: Homepage, https://theclevr.com
6
+ License: Copyright (c) 2026 Clevr Labs. All rights reserved.
7
+
8
+ This software and associated documentation files (the "Software") are the
9
+ proprietary and confidential property of Clevr Labs. Unauthorized copying,
10
+ distribution, modification, or use of the Software, in whole or in part,
11
+ is strictly prohibited without the express prior written permission of
12
+ Clevr Labs.
13
+
14
+ The Software is provided to authorized users solely for use with the
15
+ Clevr Labs API service. No license, express or implied, is granted to any
16
+ intellectual property rights in the Software.
17
+ License-File: LICENSE
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: httpx>=0.28
20
+ Requires-Dist: livekit-agents>=1.4
21
+ Requires-Dist: num2words>=0.5
22
+ Requires-Dist: numpy>=1.24
23
+ Requires-Dist: scipy>=1.10
@@ -0,0 +1,36 @@
1
+ """Clevr Labs TTS plugin for LiveKit Agents.
2
+
3
+ Provides a streaming ``tts.TTS`` implementation backed by the Clevr Labs
4
+ conversational speech model. Import and use it like any other LiveKit plugin::
5
+
6
+ from livekit.plugins import clevrlabs
7
+
8
+ session = AgentSession(tts=clevrlabs.TTS(api_key="clevr_..."), ...)
9
+ """
10
+
11
+ from .stt import is_whisper_hallucination
12
+ from .tts import TTS
13
+ from .version import __version__
14
+
15
+ __all__ = ["TTS", "is_whisper_hallucination", "__version__"]
16
+
17
+ from livekit.agents import Plugin
18
+
19
+ from .log import logger
20
+
21
+
22
+ class ClevrLabsPlugin(Plugin):
23
+ def __init__(self) -> None:
24
+ super().__init__(__name__, __version__, __package__, logger)
25
+
26
+
27
+ Plugin.register_plugin(ClevrLabsPlugin())
28
+
29
+ # Cleanup docs of unexported modules
30
+ _module = dir()
31
+ NOT_IN_ALL = [m for m in _module if m not in __all__]
32
+
33
+ __pdoc__ = {}
34
+
35
+ for n in NOT_IN_ALL:
36
+ __pdoc__[n] = False
@@ -0,0 +1,75 @@
1
+ """Text normalisation before TTS synthesis.
2
+
3
+ Translation table is built once at import time — import this module at
4
+ process startup so the C table is never rebuilt per-request.
5
+ """
6
+
7
+ import re
8
+ import unicodedata
9
+ from decimal import ROUND_HALF_UP, Decimal
10
+
11
+ from num2words import num2words
12
+
13
+ _CURRENCY_MAP = {
14
+ "$": ("dollar", "cent"),
15
+ "£": ("pound", "penny"),
16
+ "€": ("euro", "cent"),
17
+ }
18
+
19
+
20
+ def _expand_currency(m: re.Match) -> str:
21
+ symbol = m.group(1)
22
+ number = m.group(2).replace(",", "")
23
+ name, frac_name = _CURRENCY_MAP.get(symbol, ("dollar", "cent"))
24
+ amount = Decimal(number)
25
+ whole = int(amount)
26
+ cents = int((amount - whole).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) * 100)
27
+ parts = [f"{num2words(whole)} {name}{'s' if whole != 1 else ''}"]
28
+ if cents:
29
+ parts.append(f"{num2words(cents)} {frac_name}{'s' if cents != 1 else ''}")
30
+ return " and ".join(parts)
31
+
32
+
33
+ _CURRENCY_RE = re.compile(r"([$£€])([\d,]+(?:\.\d{1,2})?)")
34
+
35
+ _KEEP = set(",'?.-%$!:/£⁇¿")
36
+
37
+ _REPLACE = {
38
+ "—": ",",
39
+ "–": "-",
40
+ "’": "'",
41
+ "@": " at ",
42
+ "&": "and",
43
+ "×": " times ",
44
+ "÷": " divided by ",
45
+ "±": " plus or minus ",
46
+ "°": " degrees ",
47
+ }
48
+
49
+ _table = str.maketrans(
50
+ {
51
+ **{
52
+ chr(cp): None
53
+ for cp in range(0x110000)
54
+ if unicodedata.category(chr(cp)).startswith(("P", "S"))
55
+ and chr(cp) not in _KEEP
56
+ and chr(cp) not in _REPLACE
57
+ },
58
+ **_REPLACE,
59
+ }
60
+ )
61
+
62
+ _EMAIL = re.compile(r"\b[\w.+-]+@[\w.-]+\.[a-zA-Z]{2,}\b")
63
+ _SPACE_BEFORE_COMMA = re.compile(r" ,")
64
+ _MULTI_PUNCT = re.compile(r"[,'?.%$!:/£⁇¿-]{2,}")
65
+
66
+
67
+ def clean_text(text: str) -> str:
68
+ text = _CURRENCY_RE.sub(_expand_currency, text)
69
+ text = re.sub(r"\s+", " ", text).strip()
70
+ text = _EMAIL.sub(lambda m: m.group().replace(".", " dot ").replace("@", " at "), text)
71
+ text = text.translate(_table)
72
+ text = re.sub(r" {2,}", " ", text)
73
+ text = _SPACE_BEFORE_COMMA.sub(",", text)
74
+ text = _MULTI_PUNCT.sub(lambda m: m.group()[-1], text)
75
+ return text
@@ -0,0 +1,3 @@
1
+ import logging
2
+
3
+ logger = logging.getLogger("livekit.plugins.clevrlabs")
@@ -0,0 +1,39 @@
1
+ """STT-side helpers for use with the Clevr Labs voice model.
2
+
3
+ These utilities are not required to use ``clevrlabs.TTS`` — they exist to help you
4
+ avoid feeding bad STT output into the conversational context, which can
5
+ corrupt voice consistency.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ _WHISPER_HALLUCINATION_PHRASES = (
11
+ "thanks for watching",
12
+ "thank you for watching",
13
+ "like and subscribe",
14
+ "subscribe to",
15
+ "don't forget to subscribe",
16
+ "see you in the next video",
17
+ "subtitles by",
18
+ "subtitle by",
19
+ "transcribed by",
20
+ "amara.org",
21
+ )
22
+
23
+
24
+ def is_whisper_hallucination(text: str) -> bool:
25
+ """True if ``text`` matches a known Whisper hallucination pattern.
26
+
27
+ Whisper (and Whisper-based providers like Groq) is partly trained on
28
+ YouTube subtitle data, so it can produce phantom transcripts such as
29
+ "Thanks for watching!" on silent or noisy audio. Feeding these into
30
+ ``clevrlabs.TTS.add_user_turn`` pairs fake text with real audio in the model's
31
+ context and can cause the voice to drift.
32
+
33
+ Use this to filter STT output before it reaches ``add_user_turn`` (and
34
+ before it reaches your LLM). Only applies to Whisper-family STT — other
35
+ providers (Deepgram, AssemblyAI, Google, Azure) have different failure
36
+ modes and typically don't need this filter.
37
+ """
38
+ lower = text.lower()
39
+ return any(phrase in lower for phrase in _WHISPER_HALLUCINATION_PHRASES)
@@ -0,0 +1,313 @@
1
+ """LiveKit TTS plugin for the Clevr Labs conversational speech model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import base64
7
+ import re
8
+ import time
9
+ from math import gcd
10
+
11
+ import httpx
12
+ import numpy as np
13
+ from scipy.signal import resample_poly
14
+
15
+ from livekit.agents import APIConnectOptions, tts, utils
16
+ from livekit.agents.types import DEFAULT_API_CONNECT_OPTIONS
17
+
18
+ from ._text import clean_text
19
+ from .log import logger
20
+
21
+ _DEFAULT_SERVER_URL = "https://api.theclevr.com"
22
+
23
+
24
+ class TTS(tts.TTS):
25
+ """LiveKit TTS plugin for the Clevr Labs conversational speech model.
26
+
27
+ Usage::
28
+
29
+ from livekit.plugins import clevrlabs
30
+
31
+ tts = clevrlabs.TTS(api_key="clevr_...")
32
+
33
+ # Wire into a LiveKit AgentSession:
34
+ session = AgentSession(tts=tts, ...)
35
+
36
+ # After each user turn, feed context so the server keeps voice consistency:
37
+ tts.add_user_turn(text=transcript, audio=audio_np, sample_rate=48000)
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ *,
43
+ api_key: str,
44
+ server_url: str = _DEFAULT_SERVER_URL,
45
+ sample_rate: int = 24000,
46
+ conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
47
+ ):
48
+ super().__init__(
49
+ capabilities=tts.TTSCapabilities(streaming=True),
50
+ sample_rate=sample_rate,
51
+ num_channels=1,
52
+ )
53
+ self._server_url = server_url.rstrip("/")
54
+ self._conn_options = conn_options
55
+
56
+ self._session_id: str | None = None
57
+ self._session_started: bool = False
58
+ self._session_ready = asyncio.Event()
59
+ self._session_error: Exception | None = None
60
+ self._pending_user_turn: dict | None = None
61
+
62
+ self._http_client = httpx.AsyncClient(
63
+ base_url=self._server_url,
64
+ headers={"Authorization": f"Bearer {api_key}"},
65
+ timeout=httpx.Timeout(90.0, connect=10.0),
66
+ )
67
+
68
+ def add_user_turn(self, *, text: str, audio: np.ndarray, sample_rate: int) -> None:
69
+ """Buffer a user turn to send with the next synthesis request.
70
+
71
+ The server owns all conversation context in its KV cache. The client
72
+ only needs to forward new user audio so the server can encode it.
73
+
74
+ Args:
75
+ text: Transcript of what the user said.
76
+ audio: Float32 numpy array of the user's speech. Int16 arrays
77
+ are accepted and converted automatically.
78
+ sample_rate: Sample rate of the audio array (e.g. 48000, 16000).
79
+ """
80
+ if not text.strip() or audio.size == 0:
81
+ logger.debug(
82
+ "add_user_turn: dropping empty turn (text=%r, audio_size=%d)",
83
+ text,
84
+ audio.size,
85
+ )
86
+ return
87
+
88
+ if audio.dtype != np.float32:
89
+ audio = audio.astype(np.float32)
90
+ if np.abs(audio).max() > 1.0:
91
+ audio /= 32768.0
92
+
93
+ if sample_rate != 24000:
94
+ g = gcd(sample_rate, 24000)
95
+ audio = resample_poly(audio, 24000 // g, sample_rate // g).astype(np.float32)
96
+
97
+ audio_b64 = base64.b64encode(audio.tobytes()).decode("ascii")
98
+ self._pending_user_turn = {"speaker": 1, "text": text, "audio_b64": audio_b64}
99
+
100
+ def start_session(self) -> None:
101
+ """Eagerly start a session in the background.
102
+
103
+ Call this right after construction (from an async context) so the
104
+ session is ready before the first synthesis request. If not called,
105
+ the session is started lazily on first use.
106
+ """
107
+ asyncio.ensure_future(self._start_session_bg())
108
+
109
+ async def _start_session_bg(self) -> None:
110
+ try:
111
+ resp = await self._http_client.post("/tts/session/start")
112
+ resp.raise_for_status()
113
+ data = resp.json()
114
+ self._session_id = data["session_id"]
115
+ self._session_started = True
116
+ logger.info("Clevr session started: %s", self._session_id)
117
+ except Exception as e:
118
+ self._session_error = e
119
+ logger.error("Failed to start Clevr session: %s", e)
120
+ finally:
121
+ self._session_ready.set()
122
+
123
+ async def _ensure_session(self) -> None:
124
+ if self._session_started:
125
+ return
126
+ if not self._session_ready.is_set():
127
+ await self._session_ready.wait()
128
+ if self._session_error is not None:
129
+ raise self._session_error
130
+ if not self._session_started:
131
+ resp = await self._http_client.post("/tts/session/start")
132
+ resp.raise_for_status()
133
+ data = resp.json()
134
+ self._session_id = data["session_id"]
135
+ self._session_started = True
136
+ logger.info("Clevr session started: %s", self._session_id)
137
+
138
+ async def _end_session(self) -> None:
139
+ if not self._session_started or not self._session_id:
140
+ return
141
+ try:
142
+ resp = await self._http_client.post(
143
+ "/tts/session/end", params={"session_id": self._session_id}
144
+ )
145
+ resp.raise_for_status()
146
+ logger.info("Clevr session ended: %s", self._session_id)
147
+ except Exception:
148
+ logger.warning("Failed to end Clevr session %s", self._session_id, exc_info=True)
149
+ finally:
150
+ self._session_started = False
151
+ self._session_id = None
152
+
153
+ async def aclose(self) -> None:
154
+ await self._end_session()
155
+ await self._http_client.aclose()
156
+
157
+ @property
158
+ def model(self) -> str:
159
+ return "csm-1"
160
+
161
+ @property
162
+ def provider(self) -> str:
163
+ return "clevr"
164
+
165
+ def synthesize(
166
+ self, text: str, *, conn_options: APIConnectOptions | None = None
167
+ ) -> tts.ChunkedStream:
168
+ return self._synthesize_with_stream(
169
+ text=text, conn_options=conn_options or self._conn_options
170
+ )
171
+
172
+ def stream(self, *, conn_options: APIConnectOptions | None = None) -> ClevrLabsSynthesizeStream:
173
+ return ClevrLabsSynthesizeStream(tts=self, conn_options=conn_options or self._conn_options)
174
+
175
+
176
+ class _SentenceBuffer:
177
+ """Buffers streamed LLM tokens and emits chunks of N complete sentences."""
178
+
179
+ def __init__(self, n: int = 3) -> None:
180
+ self._n = n
181
+ self._buf = ""
182
+ self._pattern = re.compile(r"([.!?]+(?:\s+|\n+|\Z))")
183
+
184
+ def push(self, text: str) -> list:
185
+ self._buf += text
186
+ chunks = []
187
+ while True:
188
+ matches = list(self._pattern.finditer(self._buf))
189
+ if len(matches) < self._n:
190
+ break
191
+ split_idx = matches[self._n - 1].end()
192
+ chunk = self._buf[:split_idx].strip()
193
+ self._buf = self._buf[split_idx:]
194
+ if chunk:
195
+ chunks.append(chunk)
196
+ return chunks
197
+
198
+ def flush(self) -> str:
199
+ text = self._buf.strip()
200
+ self._buf = ""
201
+ return text
202
+
203
+
204
+ class ClevrLabsSynthesizeStream(tts.SynthesizeStream):
205
+ def __init__(self, *, tts: TTS, conn_options: APIConnectOptions) -> None:
206
+ super().__init__(tts=tts, conn_options=conn_options)
207
+ self._tts: TTS = tts
208
+
209
+ async def _run(self, output_emitter: tts.AudioEmitter) -> None:
210
+ request_id = utils.shortuuid()
211
+ output_emitter.initialize(
212
+ request_id=request_id,
213
+ sample_rate=self._tts.sample_rate,
214
+ num_channels=self._tts.num_channels,
215
+ mime_type="audio/pcm",
216
+ stream=True,
217
+ )
218
+ audio_bstream = utils.audio.AudioByteStream(
219
+ sample_rate=self._tts.sample_rate,
220
+ num_channels=1,
221
+ )
222
+ segment_id = utils.shortuuid()
223
+ output_emitter.start_segment(segment_id=segment_id)
224
+
225
+ buf = _SentenceBuffer(n=3)
226
+ async for data in self._input_ch:
227
+ if isinstance(data, tts.SynthesizeStream._FlushSentinel):
228
+ text = buf.flush()
229
+ if text:
230
+ await self._synthesize_segment(text, output_emitter, audio_bstream)
231
+ else:
232
+ for chunk in buf.push(data):
233
+ await self._synthesize_segment(chunk, output_emitter, audio_bstream)
234
+
235
+ text = buf.flush()
236
+ if text:
237
+ await self._synthesize_segment(text, output_emitter, audio_bstream)
238
+
239
+ output_emitter.flush()
240
+ output_emitter.end_segment()
241
+
242
+ async def _synthesize_segment(
243
+ self,
244
+ text: str,
245
+ output_emitter: tts.AudioEmitter,
246
+ audio_bstream: utils.audio.AudioByteStream,
247
+ ) -> None:
248
+ text = clean_text(text.strip())
249
+ if not text:
250
+ return
251
+
252
+ await self._tts._ensure_session()
253
+
254
+ t_start = time.perf_counter()
255
+
256
+ payload: dict = {
257
+ "text": text,
258
+ "speaker": 0,
259
+ "session_id": self._tts._session_id,
260
+ }
261
+ if self._tts._pending_user_turn is not None:
262
+ payload["context"] = [self._tts._pending_user_turn]
263
+ self._tts._pending_user_turn = None
264
+
265
+ audio_chunks: list = []
266
+ byte_buffer = b""
267
+
268
+ try:
269
+ async with self._tts._http_client.stream(
270
+ "POST", "/tts/synthesize/stream", json=payload
271
+ ) as resp:
272
+ if resp.status_code >= 400:
273
+ await resp.aread()
274
+ raise httpx.HTTPStatusError(
275
+ f"HTTP {resp.status_code}",
276
+ request=resp.request,
277
+ response=resp,
278
+ )
279
+ async for raw in resp.aiter_bytes():
280
+ byte_buffer += raw
281
+ complete = (len(byte_buffer) // 4) * 4
282
+ if complete == 0:
283
+ continue
284
+ chunk_np = np.frombuffer(byte_buffer[:complete], dtype=np.float32).copy()
285
+ byte_buffer = byte_buffer[complete:]
286
+
287
+ audio_chunks.append(chunk_np)
288
+ chunk_np = np.clip(chunk_np, -1.0, 1.0)
289
+ chunk_int16 = (chunk_np * 32767).astype(np.int16)
290
+ for frame in audio_bstream.write(chunk_int16.tobytes()):
291
+ output_emitter.push(frame.data.tobytes())
292
+
293
+ for frame in audio_bstream.flush():
294
+ output_emitter.push(frame.data.tobytes())
295
+
296
+ except asyncio.CancelledError:
297
+ raise
298
+ except httpx.ConnectError as e:
299
+ raise RuntimeError(
300
+ f"Cannot connect to Clevr TTS server at {self._tts._server_url}. "
301
+ "Check your server_url or visit https://theclevr.com for status."
302
+ ) from e
303
+ except httpx.HTTPStatusError as e:
304
+ raise RuntimeError(
305
+ f"Clevr TTS server returned {e.response.status_code}: {e.response.text}"
306
+ ) from e
307
+ finally:
308
+ total_ms = (time.perf_counter() - t_start) * 1000
309
+ audio_np = (
310
+ np.concatenate(audio_chunks) if audio_chunks else np.zeros(0, dtype=np.float32)
311
+ )
312
+ duration_s = len(audio_np) / self._tts.sample_rate
313
+ logger.info("[clevr] %.1fs audio in %.0fms", duration_s, total_ms)
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,56 @@
1
+ [project]
2
+ name = "livekit-plugins-clevrlabs"
3
+ dynamic = ["version"]
4
+ description = "LiveKit Agents TTS plugin for the Clevr Labs conversational speech model"
5
+ license = { file = "LICENSE" }
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "httpx>=0.28",
9
+ "numpy>=1.24",
10
+ "livekit-agents>=1.4",
11
+ "scipy>=1.10",
12
+ "num2words>=0.5",
13
+ ]
14
+
15
+ [project.urls]
16
+ Homepage = "https://theclevr.com"
17
+
18
+ [build-system]
19
+ requires = ["hatchling"]
20
+ build-backend = "hatchling.build"
21
+
22
+ [tool.hatch.version]
23
+ path = "livekit/plugins/clevrlabs/version.py"
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["livekit"]
27
+
28
+ [tool.hatch.build.targets.sdist]
29
+ include = ["/livekit"]
30
+
31
+ # Mirrors the livekit/agents repo ruff config so this plugin stays clean against
32
+ # their CI (`make check`) and is ready to upstream without restyling.
33
+ [tool.ruff]
34
+ line-length = 100
35
+ target-version = "py310"
36
+
37
+ [tool.ruff.lint]
38
+ select = ["E", "W", "F", "I", "B", "C4", "UP"]
39
+ ignore = ["E501"]
40
+
41
+ [tool.ruff.lint.isort]
42
+ combine-as-imports = true
43
+ known-first-party = ["livekit"]
44
+
45
+ [tool.ruff.lint.pydocstyle]
46
+ convention = "google"
47
+
48
+ # Mirrors the livekit/agents strict mypy gate. num2words and scipy ship no type
49
+ # stubs, so they're ignored the same way upstream ignores stub-less deps.
50
+ [tool.mypy]
51
+ strict = true
52
+ disallow_any_generics = false
53
+
54
+ [[tool.mypy.overrides]]
55
+ module = ["num2words", "scipy.*"]
56
+ ignore_missing_imports = true