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.
- livekit_plugins_clevrlabs-0.1.0/.gitignore +21 -0
- livekit_plugins_clevrlabs-0.1.0/LICENSE +11 -0
- livekit_plugins_clevrlabs-0.1.0/PKG-INFO +23 -0
- livekit_plugins_clevrlabs-0.1.0/livekit/plugins/clevrlabs/__init__.py +36 -0
- livekit_plugins_clevrlabs-0.1.0/livekit/plugins/clevrlabs/_text.py +75 -0
- livekit_plugins_clevrlabs-0.1.0/livekit/plugins/clevrlabs/log.py +3 -0
- livekit_plugins_clevrlabs-0.1.0/livekit/plugins/clevrlabs/py.typed +0 -0
- livekit_plugins_clevrlabs-0.1.0/livekit/plugins/clevrlabs/stt.py +39 -0
- livekit_plugins_clevrlabs-0.1.0/livekit/plugins/clevrlabs/tts.py +313 -0
- livekit_plugins_clevrlabs-0.1.0/livekit/plugins/clevrlabs/version.py +1 -0
- livekit_plugins_clevrlabs-0.1.0/pyproject.toml +56 -0
|
@@ -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
|
|
File without changes
|
|
@@ -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
|