livekit-plugins-neuphonic 1.3.0rc1__py3-none-any.whl
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/neuphonic/__init__.py +44 -0
- livekit/plugins/neuphonic/log.py +3 -0
- livekit/plugins/neuphonic/models.py +3 -0
- livekit/plugins/neuphonic/py.typed +0 -0
- livekit/plugins/neuphonic/tts.py +424 -0
- livekit/plugins/neuphonic/version.py +15 -0
- livekit_plugins_neuphonic-1.3.0rc1.dist-info/METADATA +36 -0
- livekit_plugins_neuphonic-1.3.0rc1.dist-info/RECORD +9 -0
- livekit_plugins_neuphonic-1.3.0rc1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
"""Neuphonic plugin for LiveKit Agents
|
|
16
|
+
|
|
17
|
+
See https://docs.livekit.io/agents/integrations/tts/neuphonic/ for more information.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from .tts import TTS, ChunkedStream
|
|
21
|
+
from .version import __version__
|
|
22
|
+
|
|
23
|
+
__all__ = ["TTS", "ChunkedStream", "__version__"]
|
|
24
|
+
|
|
25
|
+
from livekit.agents import Plugin
|
|
26
|
+
|
|
27
|
+
from .log import logger
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NeuphonicPlugin(Plugin):
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
super().__init__(__name__, __version__, __package__, logger)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
Plugin.register_plugin(NeuphonicPlugin())
|
|
36
|
+
|
|
37
|
+
# Cleanup docs of unexported modules
|
|
38
|
+
_module = dir()
|
|
39
|
+
NOT_IN_ALL = [m for m in _module if m not in __all__]
|
|
40
|
+
|
|
41
|
+
__pdoc__ = {}
|
|
42
|
+
|
|
43
|
+
for n in NOT_IN_ALL:
|
|
44
|
+
__pdoc__[n] = False
|
|
File without changes
|
|
@@ -0,0 +1,424 @@
|
|
|
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 # noqa: I001
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import base64
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import weakref
|
|
22
|
+
from dataclasses import dataclass, replace
|
|
23
|
+
|
|
24
|
+
import aiohttp
|
|
25
|
+
from livekit.agents import (
|
|
26
|
+
APIConnectionError,
|
|
27
|
+
APIConnectOptions,
|
|
28
|
+
APIError,
|
|
29
|
+
APIStatusError,
|
|
30
|
+
APITimeoutError,
|
|
31
|
+
tokenize,
|
|
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 # noqa: I001
|
|
39
|
+
from .models import TTSLangCodes # noqa: I001
|
|
40
|
+
|
|
41
|
+
API_AUTH_HEADER = "x-api-key"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class _TTSOptions:
|
|
46
|
+
lang_code: TTSLangCodes | str
|
|
47
|
+
encoding: str
|
|
48
|
+
sample_rate: int
|
|
49
|
+
voice_id: str
|
|
50
|
+
speed: float | None
|
|
51
|
+
api_key: str
|
|
52
|
+
base_url: str
|
|
53
|
+
word_tokenizer: tokenize.WordTokenizer
|
|
54
|
+
|
|
55
|
+
def get_http_url(self, path: str) -> str:
|
|
56
|
+
return f"{self.base_url}{path}"
|
|
57
|
+
|
|
58
|
+
def get_ws_url(self, path: str) -> str:
|
|
59
|
+
return f"{self.base_url.replace('http', 'ws', 1)}{path}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TTS(tts.TTS):
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
*,
|
|
66
|
+
api_key: str | None = None,
|
|
67
|
+
lang_code: TTSLangCodes | str = "en",
|
|
68
|
+
encoding: str = "pcm_linear",
|
|
69
|
+
voice_id: str = "8e9c4bc8-3979-48ab-8626-df53befc2090",
|
|
70
|
+
speed: float | None = 1.0,
|
|
71
|
+
sample_rate: int = 22050,
|
|
72
|
+
http_session: aiohttp.ClientSession | None = None,
|
|
73
|
+
word_tokenizer: NotGivenOr[tokenize.WordTokenizer] = NOT_GIVEN,
|
|
74
|
+
tokenizer: NotGivenOr[tokenize.SentenceTokenizer] = NOT_GIVEN,
|
|
75
|
+
base_url: str = "https://api.neuphonic.com",
|
|
76
|
+
) -> None:
|
|
77
|
+
"""
|
|
78
|
+
Create a new instance of NeuPhonic TTS.
|
|
79
|
+
|
|
80
|
+
See https://docs.neuphonic.com for more details on the NeuPhonic API.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
lang_code (TTSLangCodes | str, optional): The language code for synthesis. Defaults to "en".
|
|
84
|
+
encoding (str, optional): The audio encoding format. Defaults to "pcm_linear".
|
|
85
|
+
voice_id (str, optional): The voice ID for the desired voice.
|
|
86
|
+
speed (float, optional): The audio playback speed. Defaults to 1.0.
|
|
87
|
+
sample_rate (int, optional): The audio sample rate in Hz. Defaults to 22050.
|
|
88
|
+
api_key (str, optional): The NeuPhonic API key. If not provided, it will be read from the NEUPHONIC_API_KEY environment variable.
|
|
89
|
+
http_session (aiohttp.ClientSession | None, optional): An existing aiohttp ClientSession to use. If not provided, a new session will be created.
|
|
90
|
+
word_tokenizer (tokenize.WordTokenizer, optional): The word tokenizer to use. Defaults to tokenize.basic.WordTokenizer().
|
|
91
|
+
tokenizer (tokenize.SentenceTokenizer, optional): The sentence tokenizer to use. Defaults to tokenize.blingfire.SentenceTokenizer().
|
|
92
|
+
base_url (str, optional): The base URL for the NeuPhonic API. Defaults to "https://api.neuphonic.com".
|
|
93
|
+
""" # noqa: E501
|
|
94
|
+
|
|
95
|
+
super().__init__(
|
|
96
|
+
capabilities=tts.TTSCapabilities(streaming=True),
|
|
97
|
+
sample_rate=sample_rate,
|
|
98
|
+
num_channels=1,
|
|
99
|
+
)
|
|
100
|
+
neuphonic_api_key = api_key or os.environ.get("NEUPHONIC_API_KEY")
|
|
101
|
+
if not neuphonic_api_key:
|
|
102
|
+
raise ValueError("NEUPHONIC_API_KEY must be set")
|
|
103
|
+
|
|
104
|
+
if not is_given(word_tokenizer):
|
|
105
|
+
word_tokenizer = tokenize.basic.WordTokenizer(ignore_punctuation=False)
|
|
106
|
+
|
|
107
|
+
self._opts = _TTSOptions(
|
|
108
|
+
lang_code=lang_code,
|
|
109
|
+
encoding=encoding,
|
|
110
|
+
sample_rate=sample_rate,
|
|
111
|
+
voice_id=voice_id,
|
|
112
|
+
speed=speed,
|
|
113
|
+
api_key=neuphonic_api_key,
|
|
114
|
+
base_url=base_url,
|
|
115
|
+
word_tokenizer=word_tokenizer,
|
|
116
|
+
)
|
|
117
|
+
self._session = http_session
|
|
118
|
+
self._pool = utils.ConnectionPool[aiohttp.ClientWebSocketResponse](
|
|
119
|
+
connect_cb=self._connect_ws,
|
|
120
|
+
close_cb=self._close_ws,
|
|
121
|
+
max_session_duration=300,
|
|
122
|
+
mark_refreshed_on_get=True,
|
|
123
|
+
)
|
|
124
|
+
self._streams = weakref.WeakSet[SynthesizeStream]()
|
|
125
|
+
self._sentence_tokenizer = (
|
|
126
|
+
tokenizer if is_given(tokenizer) else tokenize.blingfire.SentenceTokenizer()
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
async def _connect_ws(self, timeout: float) -> aiohttp.ClientWebSocketResponse:
|
|
130
|
+
session = self._ensure_session()
|
|
131
|
+
url = self._opts.get_ws_url(
|
|
132
|
+
f"/speak/en?api_key={self._opts.api_key}&speed={self._opts.speed}&lang_code={self._opts.lang_code}&sampling_rate={self._opts.sample_rate}&voice_id={self._opts.voice_id}"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
headers = {API_AUTH_HEADER: self._opts.api_key}
|
|
136
|
+
return await asyncio.wait_for(session.ws_connect(url, headers=headers), timeout)
|
|
137
|
+
|
|
138
|
+
async def _close_ws(self, ws: aiohttp.ClientWebSocketResponse) -> None:
|
|
139
|
+
await ws.close()
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def model(self) -> str:
|
|
143
|
+
return "Octave"
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def provider(self) -> str:
|
|
147
|
+
return "Neuphonic"
|
|
148
|
+
|
|
149
|
+
def _ensure_session(self) -> aiohttp.ClientSession:
|
|
150
|
+
if not self._session:
|
|
151
|
+
self._session = utils.http_context.http_session()
|
|
152
|
+
|
|
153
|
+
return self._session
|
|
154
|
+
|
|
155
|
+
def prewarm(self) -> None:
|
|
156
|
+
self._pool.prewarm()
|
|
157
|
+
|
|
158
|
+
def update_options(
|
|
159
|
+
self,
|
|
160
|
+
*,
|
|
161
|
+
lang_code: NotGivenOr[TTSLangCodes | str] = NOT_GIVEN,
|
|
162
|
+
voice_id: NotGivenOr[str] = NOT_GIVEN,
|
|
163
|
+
speed: NotGivenOr[float | None] = NOT_GIVEN,
|
|
164
|
+
) -> None:
|
|
165
|
+
"""
|
|
166
|
+
Update the Text-to-Speech (TTS) configuration options.
|
|
167
|
+
|
|
168
|
+
This allows updating the TTS settings, including lang_code, voice_id, and speed.
|
|
169
|
+
If any parameter is not provided, the existing value will be retained.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
lang_code (TTSLangCodes | str, optional): The language code for synthesis.
|
|
173
|
+
voice_id (str, optional): The voice ID for the desired voice.
|
|
174
|
+
speed (float, optional): The audio playback speed.
|
|
175
|
+
"""
|
|
176
|
+
if is_given(lang_code):
|
|
177
|
+
self._opts.lang_code = lang_code
|
|
178
|
+
if is_given(voice_id):
|
|
179
|
+
self._opts.voice_id = voice_id
|
|
180
|
+
if is_given(speed):
|
|
181
|
+
self._opts.speed = speed
|
|
182
|
+
|
|
183
|
+
def synthesize(
|
|
184
|
+
self,
|
|
185
|
+
text: str,
|
|
186
|
+
*,
|
|
187
|
+
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
|
|
188
|
+
) -> ChunkedStream:
|
|
189
|
+
return ChunkedStream(tts=self, input_text=text, conn_options=conn_options)
|
|
190
|
+
|
|
191
|
+
def stream(
|
|
192
|
+
self, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
|
|
193
|
+
) -> SynthesizeStream:
|
|
194
|
+
stream = SynthesizeStream(tts=self, conn_options=conn_options)
|
|
195
|
+
self._streams.add(stream)
|
|
196
|
+
return stream
|
|
197
|
+
|
|
198
|
+
async def aclose(self) -> None:
|
|
199
|
+
for stream in list(self._streams):
|
|
200
|
+
await stream.aclose()
|
|
201
|
+
|
|
202
|
+
self._streams.clear()
|
|
203
|
+
await self._pool.aclose()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class ChunkedStream(tts.ChunkedStream):
|
|
207
|
+
"""Synthesize chunked text using the SSE endpoint"""
|
|
208
|
+
|
|
209
|
+
def __init__(
|
|
210
|
+
self,
|
|
211
|
+
*,
|
|
212
|
+
tts: TTS,
|
|
213
|
+
input_text: str,
|
|
214
|
+
conn_options: APIConnectOptions,
|
|
215
|
+
) -> None:
|
|
216
|
+
super().__init__(tts=tts, input_text=input_text, conn_options=conn_options)
|
|
217
|
+
self._tts: TTS = tts
|
|
218
|
+
self._opts = replace(tts._opts)
|
|
219
|
+
|
|
220
|
+
async def _run(self, output_emitter: tts.AudioEmitter) -> None:
|
|
221
|
+
try:
|
|
222
|
+
async with self._tts._ensure_session().post(
|
|
223
|
+
f"{self._opts.base_url}/sse/speak/{self._opts.lang_code}",
|
|
224
|
+
headers={API_AUTH_HEADER: self._opts.api_key},
|
|
225
|
+
json={
|
|
226
|
+
"text": self._input_text,
|
|
227
|
+
"voice_id": self._opts.voice_id,
|
|
228
|
+
"lang_code": self._opts.lang_code,
|
|
229
|
+
"encoding": "pcm_linear",
|
|
230
|
+
"sampling_rate": self._opts.sample_rate,
|
|
231
|
+
"speed": self._opts.speed,
|
|
232
|
+
},
|
|
233
|
+
timeout=aiohttp.ClientTimeout(
|
|
234
|
+
total=30,
|
|
235
|
+
sock_connect=self._conn_options.timeout,
|
|
236
|
+
),
|
|
237
|
+
# large read_bufsize to avoid `ValueError: Chunk too big`
|
|
238
|
+
read_bufsize=10 * 1024 * 1024,
|
|
239
|
+
) as resp:
|
|
240
|
+
resp.raise_for_status()
|
|
241
|
+
|
|
242
|
+
output_emitter.initialize(
|
|
243
|
+
request_id=utils.shortuuid(),
|
|
244
|
+
sample_rate=self._opts.sample_rate,
|
|
245
|
+
num_channels=1,
|
|
246
|
+
mime_type="audio/pcm",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
async for line in resp.content:
|
|
250
|
+
message = line.decode("utf-8")
|
|
251
|
+
if not message:
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
parsed_message = _parse_sse_message(message)
|
|
255
|
+
|
|
256
|
+
if (
|
|
257
|
+
parsed_message is not None
|
|
258
|
+
and parsed_message.get("data", {}).get("audio") is not None
|
|
259
|
+
):
|
|
260
|
+
audio_bytes = base64.b64decode(parsed_message["data"]["audio"])
|
|
261
|
+
output_emitter.push(audio_bytes)
|
|
262
|
+
|
|
263
|
+
output_emitter.flush()
|
|
264
|
+
except asyncio.TimeoutError:
|
|
265
|
+
raise APITimeoutError() from None
|
|
266
|
+
except aiohttp.ClientResponseError as e:
|
|
267
|
+
raise APIStatusError(
|
|
268
|
+
message=e.message, status_code=e.status, request_id=None, body=None
|
|
269
|
+
) from None
|
|
270
|
+
except Exception as e:
|
|
271
|
+
raise APIConnectionError() from e
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _parse_sse_message(message: str) -> dict | None:
|
|
275
|
+
"""
|
|
276
|
+
Parse each response from the SSE endpoint.
|
|
277
|
+
|
|
278
|
+
The message will either be a string reading:
|
|
279
|
+
- `event: error`
|
|
280
|
+
- `event: message`
|
|
281
|
+
- `data: { "status_code": 200, "data": {"audio": ... } }`
|
|
282
|
+
"""
|
|
283
|
+
message = message.strip()
|
|
284
|
+
|
|
285
|
+
if not message or "data" not in message:
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
_, value = message.split(": ", 1)
|
|
289
|
+
message_dict: dict = json.loads(value)
|
|
290
|
+
|
|
291
|
+
if message_dict.get("errors") is not None:
|
|
292
|
+
raise Exception(
|
|
293
|
+
f"received error status {message_dict['status_code']}:{message_dict['errors']}"
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
return message_dict
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class SynthesizeStream(tts.SynthesizeStream):
|
|
300
|
+
def __init__(self, *, tts: TTS, conn_options: APIConnectOptions):
|
|
301
|
+
super().__init__(tts=tts, conn_options=conn_options)
|
|
302
|
+
self._tts: TTS = tts
|
|
303
|
+
self._opts = replace(tts._opts)
|
|
304
|
+
self._segments_ch = utils.aio.Chan[tokenize.SentenceStream]()
|
|
305
|
+
|
|
306
|
+
async def _run(self, output_emitter: tts.AudioEmitter) -> None:
|
|
307
|
+
request_id = utils.shortuuid()
|
|
308
|
+
output_emitter.initialize(
|
|
309
|
+
request_id=request_id,
|
|
310
|
+
sample_rate=self._opts.sample_rate,
|
|
311
|
+
num_channels=1,
|
|
312
|
+
mime_type="audio/pcm",
|
|
313
|
+
stream=True,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
async def _tokenize_input() -> None:
|
|
317
|
+
chunks_stream = None
|
|
318
|
+
async for input in self._input_ch:
|
|
319
|
+
if isinstance(input, str):
|
|
320
|
+
if chunks_stream is None:
|
|
321
|
+
chunks_stream = self._tts._sentence_tokenizer.stream()
|
|
322
|
+
self._segments_ch.send_nowait(chunks_stream)
|
|
323
|
+
chunks_stream.push_text(input)
|
|
324
|
+
elif isinstance(input, self._FlushSentinel):
|
|
325
|
+
if chunks_stream:
|
|
326
|
+
chunks_stream.end_input()
|
|
327
|
+
chunks_stream = None
|
|
328
|
+
|
|
329
|
+
self._segments_ch.close()
|
|
330
|
+
|
|
331
|
+
async def _run_segments() -> None:
|
|
332
|
+
async for chunk_stream in self._segments_ch:
|
|
333
|
+
await self._run_ws(chunk_stream, output_emitter)
|
|
334
|
+
|
|
335
|
+
tasks = [
|
|
336
|
+
asyncio.create_task(_tokenize_input()),
|
|
337
|
+
asyncio.create_task(_run_segments()),
|
|
338
|
+
]
|
|
339
|
+
try:
|
|
340
|
+
await asyncio.gather(*tasks)
|
|
341
|
+
except asyncio.TimeoutError:
|
|
342
|
+
raise APITimeoutError() from None
|
|
343
|
+
except aiohttp.ClientResponseError as e:
|
|
344
|
+
raise APIStatusError(
|
|
345
|
+
message=e.message,
|
|
346
|
+
status_code=e.status,
|
|
347
|
+
request_id=request_id,
|
|
348
|
+
body=None,
|
|
349
|
+
) from None
|
|
350
|
+
except Exception as e:
|
|
351
|
+
raise APIConnectionError() from e
|
|
352
|
+
finally:
|
|
353
|
+
await utils.aio.gracefully_cancel(*tasks)
|
|
354
|
+
|
|
355
|
+
async def _run_ws(
|
|
356
|
+
self, chunks_stream: tokenize.SentenceStream, output_emitter: tts.AudioEmitter
|
|
357
|
+
) -> None:
|
|
358
|
+
segment_id = utils.shortuuid()
|
|
359
|
+
output_emitter.start_segment(segment_id=segment_id)
|
|
360
|
+
chunks = 0
|
|
361
|
+
|
|
362
|
+
async def send_task(ws: aiohttp.ClientWebSocketResponse) -> None:
|
|
363
|
+
async for sentence in chunks_stream:
|
|
364
|
+
self._mark_started()
|
|
365
|
+
|
|
366
|
+
nonlocal chunks
|
|
367
|
+
chunks += 1
|
|
368
|
+
|
|
369
|
+
msg = {"text": f"{sentence.token}<STOP>", "context_id": segment_id}
|
|
370
|
+
await ws.send_str(json.dumps(msg))
|
|
371
|
+
|
|
372
|
+
async def recv_task(ws: aiohttp.ClientWebSocketResponse) -> None:
|
|
373
|
+
while True:
|
|
374
|
+
msg = await ws.receive()
|
|
375
|
+
|
|
376
|
+
if msg.type in (
|
|
377
|
+
aiohttp.WSMsgType.CLOSE,
|
|
378
|
+
aiohttp.WSMsgType.CLOSED,
|
|
379
|
+
aiohttp.WSMsgType.CLOSING,
|
|
380
|
+
):
|
|
381
|
+
raise APIStatusError("NeuPhonic websocket connection closed unexpectedly")
|
|
382
|
+
|
|
383
|
+
if msg.type == aiohttp.WSMsgType.TEXT:
|
|
384
|
+
try:
|
|
385
|
+
resp = json.loads(msg.data)
|
|
386
|
+
except json.JSONDecodeError:
|
|
387
|
+
logger.warning("Invalid JSON from NeuPhonic")
|
|
388
|
+
continue
|
|
389
|
+
|
|
390
|
+
if resp.get("type") == "error":
|
|
391
|
+
raise APIError(f"NeuPhonic returned error: {resp}")
|
|
392
|
+
|
|
393
|
+
data = resp.get("data", {})
|
|
394
|
+
audio_data = data.get("audio")
|
|
395
|
+
if audio_data and audio_data != "" and data.get("context_id") == segment_id:
|
|
396
|
+
try:
|
|
397
|
+
b64data = base64.b64decode(audio_data)
|
|
398
|
+
if b64data:
|
|
399
|
+
output_emitter.push(b64data)
|
|
400
|
+
except Exception as e:
|
|
401
|
+
logger.warning("Failed to decode NeuPhonic audio data: %s", e)
|
|
402
|
+
|
|
403
|
+
nonlocal chunks
|
|
404
|
+
if data.get("stop"):
|
|
405
|
+
chunks -= 1
|
|
406
|
+
|
|
407
|
+
if data.get("context_id") != segment_id or chunks == 0:
|
|
408
|
+
output_emitter.end_segment()
|
|
409
|
+
break
|
|
410
|
+
elif msg.type == aiohttp.WSMsgType.BINARY:
|
|
411
|
+
pass
|
|
412
|
+
else:
|
|
413
|
+
logger.warning("Unexpected NeuPhonic message type: %s", msg.type)
|
|
414
|
+
|
|
415
|
+
async with self._tts._pool.connection(timeout=self._conn_options.timeout) as ws:
|
|
416
|
+
tasks = [
|
|
417
|
+
asyncio.create_task(send_task(ws)),
|
|
418
|
+
asyncio.create_task(recv_task(ws)),
|
|
419
|
+
]
|
|
420
|
+
|
|
421
|
+
try:
|
|
422
|
+
await asyncio.gather(*tasks)
|
|
423
|
+
finally:
|
|
424
|
+
await utils.aio.gracefully_cancel(*tasks)
|
|
@@ -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.3.0.rc1"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: livekit-plugins-neuphonic
|
|
3
|
+
Version: 1.3.0rc1
|
|
4
|
+
Summary: Neuphonic inference plugin for LiveKit Agents
|
|
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,livekit,neuphonic,realtime,video,voice
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
18
|
+
Requires-Python: >=3.9.0
|
|
19
|
+
Requires-Dist: livekit-agents>=1.3.0.rc1
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# Neuphonic plugin for LiveKit Agents
|
|
23
|
+
|
|
24
|
+
Support for voice synthesis with [Neuphonic](https://neuphonic.com).
|
|
25
|
+
|
|
26
|
+
See [https://docs.livekit.io/agents/integrations/tts/neuphonic/](https://docs.livekit.io/agents/integrations/tts/neuphonic/) for more information.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install livekit-plugins-neuphonic
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Pre-requisites
|
|
35
|
+
|
|
36
|
+
You'll need an API key from Neuphonic. It can be set as an environment variable: `NEUPHONIC_API_TOKEN`
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
livekit/plugins/neuphonic/__init__.py,sha256=c2yzK8LhbqZooNlJaX8TKKIjknTrZaEv6CmU9KF6dc4,1235
|
|
2
|
+
livekit/plugins/neuphonic/log.py,sha256=rAHz71IcbvPkixndXBVffPQsmWUKTLqRaYRuPIxO29w,72
|
|
3
|
+
livekit/plugins/neuphonic/models.py,sha256=dn6xtU7qJOI5XvEFupyww8IbCcwt5Ki-yS7ua_v6YxM,96
|
|
4
|
+
livekit/plugins/neuphonic/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
livekit/plugins/neuphonic/tts.py,sha256=-0KyTombdOi8PRbzRjgEyclD1FbLjm3xFpEdcpMp9Ds,15472
|
|
6
|
+
livekit/plugins/neuphonic/version.py,sha256=OKQs7wtWURKwwK_IzHkILuCJO7mYgw31lRlFok6wCwk,604
|
|
7
|
+
livekit_plugins_neuphonic-1.3.0rc1.dist-info/METADATA,sha256=fV6xd2LrrF7kMIQjDQ9ajNd8rYGxuGtT_732zl1gvpk,1328
|
|
8
|
+
livekit_plugins_neuphonic-1.3.0rc1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
9
|
+
livekit_plugins_neuphonic-1.3.0rc1.dist-info/RECORD,,
|