livekit-plugins-lokutor 1.8.12__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,95 @@
1
+ Metadata-Version: 2.5
2
+ Name: livekit-plugins-lokutor
3
+ Version: 1.8.12
4
+ Summary: Lokutor speech-to-text and text-to-speech 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: STT,TTS,audio,livekit,lokutor,realtime,voice,webrtc
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.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Multimedia :: Sound/Audio
18
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
+ Requires-Python: >=3.10.0
20
+ Requires-Dist: livekit-agents>=1.8.12
21
+ Description-Content-Type: text/markdown
22
+
23
+ # LiveKit Plugins Lokutor
24
+
25
+ **Lokutor** speech-to-text and text-to-speech for [LiveKit Agents](https://docs.livekit.io/agents/).
26
+
27
+ Lokutor builds its own small speech models: Conv 2.0 for recognition and Versa 2.0 for synthesis, each one model for nine languages (English, Spanish, Catalan, Galician, Basque, Portuguese, French, Italian and German). See [lokutor.com](https://lokutor.com) and the [API docs](https://docs.lokutor.com).
28
+
29
+ LiveKit docs: [speech-to-text](https://docs.livekit.io/agents/models/stt/lokutor/) and [text-to-speech](https://docs.livekit.io/agents/models/tts/lokutor/) (live with the next release).
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ pip install livekit-plugins-lokutor
35
+ ```
36
+
37
+ Set `LOKUTOR_API_KEY`, or pass `api_key=` to `STT` and `TTS`.
38
+
39
+ ## Usage
40
+
41
+ ### In an agent
42
+
43
+ ```python
44
+ from livekit.agents import AgentSession
45
+ from livekit.plugins import lokutor, openai, silero
46
+
47
+ session = AgentSession(
48
+ stt=lokutor.STT(language="es"),
49
+ llm=openai.LLM(),
50
+ tts=lokutor.TTS(voice="F1", language="es"),
51
+ vad=silero.VAD.load(),
52
+ )
53
+ ```
54
+
55
+ ### Speech-to-text
56
+
57
+ `STT` streams over `WS /ws/stt`: interim transcripts while the user speaks, then a final transcript when the server sees the utterance end (a pause of about 0.7 s). `recognize()` transcribes one clip with `POST /stt/transcribe`. The text comes back written out, with punctuation, casing, digits and e-mail addresses.
58
+
59
+ ```python
60
+ stt = lokutor.STT(language="en")
61
+ event = await stt.recognize(frames) # a list of rtc.AudioFrame, any sample rate
62
+ print(event.alternatives[0].text)
63
+ ```
64
+
65
+ | Parameter | Type | Default | Description |
66
+ |-----------|------|---------|-------------|
67
+ | `api_key` | `str` | env `LOKUTOR_API_KEY` | Lokutor API key |
68
+ | `language` | `str` | detected | One of the nine languages; regional codes such as `es-ES` are accepted. Set it whenever you know it: it keeps one-word replies reliable and sets how the text is written. Any other value is left to detection |
69
+ | `base_url` | `str` | `"wss://api.lokutor.com"` | API base URL |
70
+
71
+ ### Text-to-speech
72
+
73
+ ```python
74
+ tts = lokutor.TTS(voice="F1", language="en")
75
+ async with tts:
76
+ stream = tts.stream()
77
+ stream.push_text("Hello, world!")
78
+ stream.end_input()
79
+ async for audio in stream:
80
+ print(f"Got audio: {audio.frame.duration:.2f}s")
81
+ ```
82
+
83
+ | Parameter | Type | Default | Description |
84
+ |-----------|------|---------|-------------|
85
+ | `api_key` | `str` | env `LOKUTOR_API_KEY` | Lokutor API key |
86
+ | `voice` | `str` | `"F1"` | Voice slot, `F1`-`F5` (female) or `M1`-`M5` (male). Each language has its own native speaker in each slot |
87
+ | `language` | `str \| None` | `"en"` | One of the nine languages; any other value uses the English speakers |
88
+ | `tokenizer` | `tokenize.SentenceTokenizer` | `blingfire` | Splits the streamed input into sentences; each sentence is one request, so the voice has per-sentence prosody |
89
+ | `base_url` | `str` | `"wss://api.lokutor.com"` | API base URL |
90
+
91
+ Audio comes out at 44.1 kHz mono; the pipeline resamples if it needs another rate.
92
+
93
+ ## License
94
+
95
+ Apache 2.0
@@ -0,0 +1,73 @@
1
+ # LiveKit Plugins Lokutor
2
+
3
+ **Lokutor** speech-to-text and text-to-speech for [LiveKit Agents](https://docs.livekit.io/agents/).
4
+
5
+ Lokutor builds its own small speech models: Conv 2.0 for recognition and Versa 2.0 for synthesis, each one model for nine languages (English, Spanish, Catalan, Galician, Basque, Portuguese, French, Italian and German). See [lokutor.com](https://lokutor.com) and the [API docs](https://docs.lokutor.com).
6
+
7
+ LiveKit docs: [speech-to-text](https://docs.livekit.io/agents/models/stt/lokutor/) and [text-to-speech](https://docs.livekit.io/agents/models/tts/lokutor/) (live with the next release).
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install livekit-plugins-lokutor
13
+ ```
14
+
15
+ Set `LOKUTOR_API_KEY`, or pass `api_key=` to `STT` and `TTS`.
16
+
17
+ ## Usage
18
+
19
+ ### In an agent
20
+
21
+ ```python
22
+ from livekit.agents import AgentSession
23
+ from livekit.plugins import lokutor, openai, silero
24
+
25
+ session = AgentSession(
26
+ stt=lokutor.STT(language="es"),
27
+ llm=openai.LLM(),
28
+ tts=lokutor.TTS(voice="F1", language="es"),
29
+ vad=silero.VAD.load(),
30
+ )
31
+ ```
32
+
33
+ ### Speech-to-text
34
+
35
+ `STT` streams over `WS /ws/stt`: interim transcripts while the user speaks, then a final transcript when the server sees the utterance end (a pause of about 0.7 s). `recognize()` transcribes one clip with `POST /stt/transcribe`. The text comes back written out, with punctuation, casing, digits and e-mail addresses.
36
+
37
+ ```python
38
+ stt = lokutor.STT(language="en")
39
+ event = await stt.recognize(frames) # a list of rtc.AudioFrame, any sample rate
40
+ print(event.alternatives[0].text)
41
+ ```
42
+
43
+ | Parameter | Type | Default | Description |
44
+ |-----------|------|---------|-------------|
45
+ | `api_key` | `str` | env `LOKUTOR_API_KEY` | Lokutor API key |
46
+ | `language` | `str` | detected | One of the nine languages; regional codes such as `es-ES` are accepted. Set it whenever you know it: it keeps one-word replies reliable and sets how the text is written. Any other value is left to detection |
47
+ | `base_url` | `str` | `"wss://api.lokutor.com"` | API base URL |
48
+
49
+ ### Text-to-speech
50
+
51
+ ```python
52
+ tts = lokutor.TTS(voice="F1", language="en")
53
+ async with tts:
54
+ stream = tts.stream()
55
+ stream.push_text("Hello, world!")
56
+ stream.end_input()
57
+ async for audio in stream:
58
+ print(f"Got audio: {audio.frame.duration:.2f}s")
59
+ ```
60
+
61
+ | Parameter | Type | Default | Description |
62
+ |-----------|------|---------|-------------|
63
+ | `api_key` | `str` | env `LOKUTOR_API_KEY` | Lokutor API key |
64
+ | `voice` | `str` | `"F1"` | Voice slot, `F1`-`F5` (female) or `M1`-`M5` (male). Each language has its own native speaker in each slot |
65
+ | `language` | `str \| None` | `"en"` | One of the nine languages; any other value uses the English speakers |
66
+ | `tokenizer` | `tokenize.SentenceTokenizer` | `blingfire` | Splits the streamed input into sentences; each sentence is one request, so the voice has per-sentence prosody |
67
+ | `base_url` | `str` | `"wss://api.lokutor.com"` | API base URL |
68
+
69
+ Audio comes out at 44.1 kHz mono; the pipeline resamples if it needs another rate.
70
+
71
+ ## License
72
+
73
+ Apache 2.0
@@ -0,0 +1,30 @@
1
+ """Lokutor plugin for LiveKit Agents: speech-to-text and text-to-speech.
2
+
3
+ See https://docs.lokutor.com for the API behind it.
4
+ """
5
+
6
+ from .stt import STT, SpeechStream
7
+ from .tts import TTS, ChunkedStream, SynthesizeStream
8
+ from .version import __version__
9
+
10
+ __all__ = ["STT", "SpeechStream", "TTS", "ChunkedStream", "SynthesizeStream", "__version__"]
11
+
12
+ from livekit.agents import Plugin
13
+
14
+ from .log import logger
15
+
16
+
17
+ class LokutorPlugin(Plugin):
18
+ def __init__(self) -> None:
19
+ super().__init__(__name__, __version__, __package__, logger)
20
+
21
+
22
+ Plugin.register_plugin(LokutorPlugin())
23
+
24
+ _module = dir()
25
+ NOT_IN_ALL = [m for m in _module if m not in __all__]
26
+
27
+ __pdoc__ = {}
28
+
29
+ for n in NOT_IN_ALL:
30
+ __pdoc__[n] = False
@@ -0,0 +1,3 @@
1
+ import logging
2
+
3
+ logger = logging.getLogger("livekit.plugins.lokutor")
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ # Voice slots: five female, five male. Each language has its own native speaker in
6
+ # each slot, so the speaker heard depends on the language too. See
7
+ # https://docs.lokutor.com/voices-languages-models#voices
8
+ VoiceID = Literal[
9
+ "F1",
10
+ "F2",
11
+ "F3",
12
+ "F4",
13
+ "F5",
14
+ "M1",
15
+ "M2",
16
+ "M3",
17
+ "M4",
18
+ "M5",
19
+ ]
20
+
21
+ # The nine languages Lokutor speaks and recognises. A language outside this list
22
+ # uses the English speakers for synthesis and is detected for recognition. See
23
+ # https://docs.lokutor.com/voices-languages-models#languages
24
+ TTSLanguage = Literal["en", "es", "ca", "gl", "eu", "pt", "fr", "it", "de"]
25
+ STTLanguage = TTSLanguage
26
+ STT_LANGUAGES: frozenset[str] = frozenset(("en", "es", "ca", "gl", "eu", "pt", "fr", "it", "de"))
27
+
28
+ TTSModels = Literal["versa-2.0"]
29
+ TTS_MODEL: TTSModels = "versa-2.0"
30
+ STT_MODEL = "conv-2.0"
31
+
32
+ DEFAULT_VOICE_ID: VoiceID = "F1"
33
+ DEFAULT_LANGUAGE: TTSLanguage = "en"
34
+ DEFAULT_SAMPLE_RATE: int = 44100
35
+ DEFAULT_BASE_URL = "wss://api.lokutor.com"
@@ -0,0 +1,449 @@
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
+ """Lokutor speech-to-text.
16
+
17
+ Streaming uses ``WS /ws/stt``. Wire contract:
18
+
19
+ client -> server {"language": "en"} ("" lets the model detect it)
20
+ server -> client {"type": "status", "data": "ready"}
21
+ client -> server binary frames: 16-bit PCM, 16 kHz, mono
22
+ server -> client {"type": "transcript", "data": ..., "isFinal": bool}
23
+ client -> server {"type": "end"}
24
+ server -> client the last final, then {"type": "status", "data": "done"}
25
+
26
+ The server decides where an utterance ends (a pause of about 0.7 s). ``data`` is the
27
+ whole utterance so far on every partial, and the final carries the utterance once,
28
+ transcribed in one pass. Single-shot recognition uses ``POST /stt/transcribe``.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import asyncio
34
+ import base64
35
+ import json
36
+ import os
37
+ from dataclasses import dataclass
38
+
39
+ import aiohttp
40
+
41
+ from livekit import rtc
42
+ from livekit.agents import (
43
+ DEFAULT_API_CONNECT_OPTIONS,
44
+ APIConnectionError,
45
+ APIConnectOptions,
46
+ APIError,
47
+ APIStatusError,
48
+ APITimeoutError,
49
+ stt,
50
+ utils,
51
+ )
52
+ from livekit.agents.language import LanguageCode
53
+ from livekit.agents.types import NOT_GIVEN, NotGivenOr
54
+ from livekit.agents.utils import AudioBuffer, is_given
55
+
56
+ from .log import logger
57
+ from .models import DEFAULT_BASE_URL, STT_LANGUAGES, STT_MODEL, STTLanguage
58
+
59
+ _SAMPLE_RATE = 16000
60
+
61
+
62
+ @dataclass
63
+ class _STTOptions:
64
+ api_key: str
65
+ language: str | None
66
+ base_url: str
67
+
68
+
69
+ def _api_language(language: str | None) -> str:
70
+ """The language as the API takes it ("en-US" -> "en"), or "" to have it detected.
71
+
72
+ The recogniser writes the text by the rules of the language it is given, so a
73
+ language it does not serve is left to detection rather than forced.
74
+ """
75
+ if not language:
76
+ return ""
77
+ code = LanguageCode(language).language
78
+ return code if code in STT_LANGUAGES else ""
79
+
80
+
81
+ def _http_base(base_url: str) -> str:
82
+ if base_url.startswith("wss://"):
83
+ return "https://" + base_url[len("wss://") :]
84
+ if base_url.startswith("ws://"):
85
+ return "http://" + base_url[len("ws://") :]
86
+ return base_url
87
+
88
+
89
+ class STT(stt.STT):
90
+ """Lokutor Speech-to-Text integration.
91
+
92
+ One model for nine languages: English, Spanish, Catalan, Galician, Basque,
93
+ Portuguese, French, Italian and German. The text comes back written out, with
94
+ punctuation, casing, digits and e-mail addresses.
95
+
96
+ Args:
97
+ api_key: Lokutor API key. If not provided, reads from ``LOKUTOR_API_KEY``
98
+ environment variable.
99
+ language: Language code (e.g. ``"en"``, ``"es-ES"``). Send it whenever it is
100
+ known: it keeps one-word replies reliable and sets how the text is
101
+ written. Left unset, or a language the model does not serve, the
102
+ language is detected.
103
+ base_url: Base URL for the Lokutor API. Defaults to ``"wss://api.lokutor.com"``.
104
+ http_session: Optional shared ``aiohttp.ClientSession``.
105
+ """
106
+
107
+ def __init__(
108
+ self,
109
+ *,
110
+ api_key: str | None = None,
111
+ language: NotGivenOr[STTLanguage | str] = NOT_GIVEN,
112
+ base_url: str = DEFAULT_BASE_URL,
113
+ http_session: aiohttp.ClientSession | None = None,
114
+ ) -> None:
115
+ super().__init__(
116
+ capabilities=stt.STTCapabilities(
117
+ streaming=True,
118
+ interim_results=True,
119
+ offline_recognize=True,
120
+ ),
121
+ )
122
+ lokutor_api_key = api_key or os.environ.get("LOKUTOR_API_KEY")
123
+ if not lokutor_api_key:
124
+ raise ValueError(
125
+ "Lokutor API key is required, either as argument or set"
126
+ " LOKUTOR_API_KEY environment variable"
127
+ )
128
+ self._opts = _STTOptions(
129
+ api_key=lokutor_api_key,
130
+ language=language if is_given(language) else None,
131
+ base_url=base_url,
132
+ )
133
+ self._session = http_session
134
+
135
+ @property
136
+ def model(self) -> str:
137
+ """Returns the model name ``"conv-2.0"``."""
138
+ return STT_MODEL
139
+
140
+ @property
141
+ def provider(self) -> str:
142
+ """Returns the provider name ``"Lokutor"``."""
143
+ return "Lokutor"
144
+
145
+ def _ensure_session(self) -> aiohttp.ClientSession:
146
+ if not self._session:
147
+ self._session = utils.http_context.http_session()
148
+ return self._session
149
+
150
+ async def _recognize_impl(
151
+ self,
152
+ buffer: AudioBuffer,
153
+ *,
154
+ language: NotGivenOr[str] = NOT_GIVEN,
155
+ conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
156
+ ) -> stt.SpeechEvent:
157
+ lang = _api_language(language if is_given(language) else self._opts.language)
158
+ frame = rtc.combine_audio_frames(buffer)
159
+ body: dict[str, str] = {
160
+ "audio": base64.b64encode(frame.to_wav_bytes()).decode(),
161
+ "format": "wav",
162
+ }
163
+ if lang:
164
+ body["language"] = lang
165
+
166
+ try:
167
+ async with self._ensure_session().post(
168
+ f"{_http_base(self._opts.base_url)}/stt/transcribe",
169
+ json=body,
170
+ headers={"X-API-Key": self._opts.api_key},
171
+ timeout=aiohttp.ClientTimeout(total=conn_options.timeout),
172
+ ) as resp:
173
+ if resp.status != 200:
174
+ raise APIStatusError(
175
+ "Lokutor transcription failed",
176
+ status_code=resp.status,
177
+ request_id=None,
178
+ body=None,
179
+ )
180
+ data = await resp.json()
181
+ except asyncio.TimeoutError:
182
+ raise APITimeoutError() from None
183
+ except APIError:
184
+ raise
185
+ except Exception as e:
186
+ # Keep only the exception type: a transport error can carry the request
187
+ # URL, headers or a response body.
188
+ raise APIConnectionError(type(e).__name__) from None
189
+
190
+ return stt.SpeechEvent(
191
+ type=stt.SpeechEventType.FINAL_TRANSCRIPT,
192
+ request_id=utils.shortuuid("lokutor-"),
193
+ alternatives=[
194
+ stt.SpeechData(
195
+ # Without a requested language, the one the API reports (detected), if it serves it.
196
+ language=LanguageCode(
197
+ lang or _api_language(str(data.get("language") or "")) or "multi"
198
+ ),
199
+ text=str(data.get("text") or "").strip(),
200
+ start_time=0.0,
201
+ end_time=frame.duration,
202
+ )
203
+ ],
204
+ )
205
+
206
+ def stream(
207
+ self,
208
+ *,
209
+ language: NotGivenOr[str] = NOT_GIVEN,
210
+ conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
211
+ ) -> SpeechStream:
212
+ """Create a streaming recognition session over ``WS /ws/stt``."""
213
+ return SpeechStream(
214
+ stt=self,
215
+ conn_options=conn_options,
216
+ language=language if is_given(language) else self._opts.language,
217
+ )
218
+
219
+
220
+ class SpeechStream(stt.SpeechStream):
221
+ """Streaming recognition: interim transcripts while the user speaks, a final one
222
+ when the server sees the utterance end."""
223
+
224
+ def __init__(
225
+ self,
226
+ *,
227
+ stt: STT,
228
+ conn_options: APIConnectOptions,
229
+ language: str | None,
230
+ ) -> None:
231
+ super().__init__(stt=stt, conn_options=conn_options, sample_rate=_SAMPLE_RATE)
232
+ self._stt: STT = stt
233
+ self._api_lang = _api_language(language)
234
+ self._language = LanguageCode(self._api_lang or "multi")
235
+ self._speaking = False
236
+ self._request_id = ""
237
+ self._input_done = False
238
+ self._done = False
239
+ self._audio_sent = 0.0
240
+ self._audio_reported = 0.0
241
+ # Audio position (seconds into this stream) where the last final transcript ended: the earliest
242
+ # the next utterance can start. The server sends no word timings.
243
+ self._utterance_floor = 0.0
244
+
245
+ async def _connect_ws(self) -> aiohttp.ClientWebSocketResponse:
246
+ try:
247
+ return await asyncio.wait_for(
248
+ self._stt._ensure_session().ws_connect(
249
+ f"{self._stt._opts.base_url}/ws/stt",
250
+ max_msg_size=0,
251
+ # In a header, not the query string, so the key stays out of
252
+ # exception messages, tracing and access logs.
253
+ headers={"X-API-Key": self._stt._opts.api_key},
254
+ ),
255
+ self._conn_options.timeout,
256
+ )
257
+ except asyncio.TimeoutError:
258
+ raise APITimeoutError() from None
259
+ except aiohttp.WSServerHandshakeError as e:
260
+ # 401 bad key, 402 no balance: final. 429, the account's concurrent sessions in use, is
261
+ # retryable (APIStatusError's rule): a reconnect's old session is likely about to be released.
262
+ raise APIStatusError(
263
+ "Lokutor STT refused the connection",
264
+ status_code=e.status,
265
+ request_id=None,
266
+ body=None,
267
+ ) from None
268
+ except Exception as e:
269
+ raise APIConnectionError(type(e).__name__) from None
270
+
271
+ async def _run(self) -> None:
272
+ # A retry starts on a new connection, which knows nothing of an utterance the last one cut off: end
273
+ # it for the consumer, and let the new connection's first transcript open its own.
274
+ if self._speaking:
275
+ self._event_ch.send_nowait(
276
+ stt.SpeechEvent(type=stt.SpeechEventType.END_OF_SPEECH, request_id=self._request_id)
277
+ )
278
+ self._speaking = False
279
+ self._request_id = ""
280
+ self._input_done = False
281
+ self._done = False
282
+ # start_time_offset restarts at this connection's first frame (restart_provider_clock), so
283
+ # transcript times count from here too. The last connection's usage was reported when it ended.
284
+ self._audio_sent = 0.0
285
+ self._audio_reported = 0.0
286
+ self._utterance_floor = 0.0
287
+ ws = await self._connect_ws()
288
+ try:
289
+ await ws.send_str(json.dumps({"language": self._api_lang}))
290
+ await asyncio.wait_for(self._await_ready(ws), self._conn_options.timeout)
291
+
292
+ send_task = asyncio.create_task(self._send_task(ws))
293
+ recv_task = asyncio.create_task(self._recv_task(ws))
294
+ try:
295
+ await asyncio.wait({send_task, recv_task}, return_when=asyncio.FIRST_COMPLETED)
296
+ if send_task.done():
297
+ send_task.result()
298
+ # Input has ended: the last final transcript and "done" must follow within the
299
+ # timeout. A live stream waits on the caller for as long as it lasts; this
300
+ # bounds only the wait for a server that stops answering after "end".
301
+ await asyncio.wait_for(recv_task, self._conn_options.timeout)
302
+ else:
303
+ recv_task.result()
304
+ finally:
305
+ await utils.aio.gracefully_cancel(send_task, recv_task)
306
+ except asyncio.TimeoutError:
307
+ raise APITimeoutError() from None
308
+ except aiohttp.ClientError as e:
309
+ # A send on a socket that dropped mid-stream raises before the receiver sees the close;
310
+ # as a connection error it is retried like any other drop.
311
+ raise APIConnectionError(type(e).__name__) from None
312
+ finally:
313
+ self._report_usage()
314
+ await ws.close()
315
+
316
+ async def _await_ready(self, ws: aiohttp.ClientWebSocketResponse) -> None:
317
+ while True:
318
+ msg = await ws.receive()
319
+ if msg.type != aiohttp.WSMsgType.TEXT:
320
+ raise APIConnectionError("Lokutor STT closed before it was ready")
321
+ try:
322
+ data = json.loads(msg.data)
323
+ except json.JSONDecodeError:
324
+ continue
325
+ if not isinstance(data, dict):
326
+ continue
327
+ if data.get("type") == "error":
328
+ raise APIStatusError(
329
+ "Lokutor STT rejected the session",
330
+ status_code=-1,
331
+ request_id=None,
332
+ body=None,
333
+ retryable=False,
334
+ )
335
+ if data.get("type") == "status" and data.get("data") == "ready":
336
+ self._note_language(data.get("lang"))
337
+ return
338
+
339
+ async def _send_task(self, ws: aiohttp.ClientWebSocketResponse) -> None:
340
+ async for data in self._input_ch:
341
+ # A flush needs nothing here: the server ends an utterance on its own pause.
342
+ if isinstance(data, rtc.AudioFrame):
343
+ audio = data.data.tobytes()
344
+ if audio:
345
+ self._audio_sent += data.samples_per_channel / data.sample_rate
346
+ await ws.send_bytes(audio)
347
+ self._input_done = True
348
+ await ws.send_str(json.dumps({"type": "end"}))
349
+
350
+ async def _recv_task(self, ws: aiohttp.ClientWebSocketResponse) -> None:
351
+ while True:
352
+ msg = await ws.receive()
353
+ if msg.type in (
354
+ aiohttp.WSMsgType.CLOSED,
355
+ aiohttp.WSMsgType.CLOSE,
356
+ aiohttp.WSMsgType.CLOSING,
357
+ ):
358
+ # Only "done" ends a stream: a close before it, even after end_input(), may have
359
+ # lost the last final transcript.
360
+ if self._done:
361
+ return
362
+ raise APIConnectionError("Lokutor STT closed before it finished")
363
+ if msg.type == aiohttp.WSMsgType.ERROR:
364
+ raise APIConnectionError("Lokutor STT connection failed")
365
+ if msg.type != aiohttp.WSMsgType.TEXT:
366
+ continue
367
+ try:
368
+ data = json.loads(msg.data)
369
+ except json.JSONDecodeError:
370
+ logger.debug("Ignoring a non-JSON frame from Lokutor STT")
371
+ continue
372
+ if not isinstance(data, dict):
373
+ continue
374
+ kind = data.get("type")
375
+ if kind == "error":
376
+ # Not the provider's message: it may echo customer content.
377
+ raise APIStatusError(
378
+ "Lokutor STT returned an error",
379
+ status_code=-1,
380
+ request_id=None,
381
+ body=None,
382
+ retryable=False,
383
+ )
384
+ if kind == "status" and data.get("data") == "done":
385
+ self._done = True
386
+ return
387
+ if kind == "transcript":
388
+ self._note_language(data.get("lang"))
389
+ self._on_transcript(
390
+ str(data.get("data") or "").strip(), data.get("isFinal") is True
391
+ )
392
+
393
+ def _note_language(self, lang: object) -> None:
394
+ # With no language requested, label transcripts with the one the server reports, if it
395
+ # serves it; "multi" otherwise.
396
+ if not self._api_lang and isinstance(lang, str) and _api_language(lang):
397
+ self._language = LanguageCode(_api_language(lang))
398
+
399
+ def _on_transcript(self, text: str, final: bool) -> None:
400
+ if not text:
401
+ # An empty final after interims (a cough, noise) still closes the utterance they opened.
402
+ if final and self._speaking:
403
+ self._end_utterance()
404
+ return
405
+ if not self._speaking:
406
+ self._speaking = True
407
+ self._request_id = utils.shortuuid("lokutor-")
408
+ self._event_ch.send_nowait(stt.SpeechEvent(type=stt.SpeechEventType.START_OF_SPEECH))
409
+ self._event_ch.send_nowait(
410
+ stt.SpeechEvent(
411
+ type=(
412
+ stt.SpeechEventType.FINAL_TRANSCRIPT
413
+ if final
414
+ else stt.SpeechEventType.INTERIM_TRANSCRIPT
415
+ ),
416
+ request_id=self._request_id,
417
+ alternatives=[
418
+ stt.SpeechData(
419
+ language=self._language,
420
+ text=text,
421
+ start_time=self.start_time_offset + self._utterance_floor,
422
+ end_time=self.start_time_offset + self._audio_sent,
423
+ )
424
+ ],
425
+ )
426
+ )
427
+ if final:
428
+ self._end_utterance()
429
+
430
+ def _end_utterance(self) -> None:
431
+ self._event_ch.send_nowait(
432
+ stt.SpeechEvent(type=stt.SpeechEventType.END_OF_SPEECH, request_id=self._request_id)
433
+ )
434
+ self._report_usage()
435
+ self._utterance_floor = self._audio_sent
436
+ self._speaking = False
437
+ self._request_id = ""
438
+
439
+ def _report_usage(self) -> None:
440
+ duration = self._audio_sent - self._audio_reported
441
+ if duration <= 0:
442
+ return
443
+ self._audio_reported = self._audio_sent
444
+ self._event_ch.send_nowait(
445
+ stt.SpeechEvent(
446
+ type=stt.SpeechEventType.RECOGNITION_USAGE,
447
+ recognition_usage=stt.RecognitionUsage(audio_duration=duration),
448
+ )
449
+ )
@@ -0,0 +1,390 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import weakref
7
+ from dataclasses import dataclass, replace
8
+ from typing import Any
9
+
10
+ import aiohttp
11
+
12
+ from livekit.agents import (
13
+ APIConnectionError,
14
+ APIConnectOptions,
15
+ APIError,
16
+ APIStatusError,
17
+ APITimeoutError,
18
+ tokenize,
19
+ tts,
20
+ utils,
21
+ )
22
+ from livekit.agents.language import LanguageCode
23
+ from livekit.agents.types import DEFAULT_API_CONNECT_OPTIONS, NOT_GIVEN, NotGivenOr
24
+ from livekit.agents.utils import is_given
25
+
26
+ from .log import logger
27
+ from .models import (
28
+ DEFAULT_BASE_URL,
29
+ DEFAULT_SAMPLE_RATE,
30
+ DEFAULT_VOICE_ID,
31
+ TTS_MODEL,
32
+ TTSLanguage,
33
+ VoiceID,
34
+ )
35
+
36
+
37
+ @dataclass
38
+ class _TTSOptions:
39
+ api_key: str
40
+ voice: str
41
+ language: str | None
42
+ base_url: str
43
+ sample_rate: int
44
+
45
+ def get_ws_url(self) -> str:
46
+ return f"{self.base_url}/ws/tts"
47
+
48
+
49
+ class TTS(tts.TTS):
50
+ """Lokutor Text-to-Speech integration.
51
+
52
+ Connects to the Lokutor WebSocket API to synthesize speech. Supports
53
+ streaming and non-streaming synthesis with multiple voices and languages.
54
+
55
+ Args:
56
+ api_key: Lokutor API key. If not provided, reads from ``LOKUTOR_API_KEY``
57
+ environment variable.
58
+ voice: Voice slot to use, ``"F1"``-``"F5"`` or ``"M1"``-``"M5"``. Each
59
+ language has its own native speaker in each slot. Defaults to ``"F1"``.
60
+ language: Language code: ``"en"``, ``"es"``, ``"ca"``, ``"gl"``,
61
+ ``"eu"``, ``"pt"``, ``"fr"``, ``"it"`` or ``"de"``. Any other value
62
+ uses the English speakers. Defaults to ``"en"``.
63
+ base_url: Base URL for the Lokutor API. Defaults to
64
+ ``"wss://api.lokutor.com"``.
65
+ tokenizer: Sentence tokenizer for streaming input. The agent pushes LLM
66
+ token deltas; each sentence becomes one request so the prosody is
67
+ per sentence. Defaults to ``tokenize.blingfire.SentenceTokenizer``.
68
+ http_session: Optional shared ``aiohttp.ClientSession``. If not
69
+ provided, a new session will be created.
70
+
71
+ Note:
72
+ Lokutor's TTS endpoint always returns audio at 44.1 kHz; the output
73
+ sample rate is fixed and not configurable. The LiveKit pipeline
74
+ resamples downstream if a different rate is required.
75
+ """
76
+
77
+ def __init__(
78
+ self,
79
+ *,
80
+ api_key: str | None = None,
81
+ voice: VoiceID | str = DEFAULT_VOICE_ID,
82
+ language: TTSLanguage | str | None = "en",
83
+ base_url: str = DEFAULT_BASE_URL,
84
+ tokenizer: NotGivenOr[tokenize.SentenceTokenizer] = NOT_GIVEN,
85
+ http_session: aiohttp.ClientSession | None = None,
86
+ ) -> None:
87
+ lokutor_api_key = api_key or os.environ.get("LOKUTOR_API_KEY")
88
+ if not lokutor_api_key:
89
+ raise ValueError(
90
+ "Lokutor API key is required, either as argument or set"
91
+ " LOKUTOR_API_KEY environment variable"
92
+ )
93
+
94
+ super().__init__(
95
+ capabilities=tts.TTSCapabilities(
96
+ streaming=True,
97
+ aligned_transcript=False,
98
+ ),
99
+ sample_rate=DEFAULT_SAMPLE_RATE,
100
+ num_channels=1,
101
+ )
102
+
103
+ self._opts = _TTSOptions(
104
+ api_key=lokutor_api_key,
105
+ voice=voice,
106
+ language=language,
107
+ base_url=base_url,
108
+ sample_rate=DEFAULT_SAMPLE_RATE,
109
+ )
110
+
111
+ self._sentence_tokenizer = (
112
+ tokenizer if is_given(tokenizer) else tokenize.blingfire.SentenceTokenizer()
113
+ )
114
+ self._session = http_session
115
+ self._pool = utils.ConnectionPool[aiohttp.ClientWebSocketResponse](
116
+ connect_cb=self._connect_ws,
117
+ close_cb=self._close_ws,
118
+ max_session_duration=300,
119
+ mark_refreshed_on_get=True,
120
+ )
121
+ self._streams = weakref.WeakSet[SynthesizeStream]()
122
+
123
+ @property
124
+ def model(self) -> str:
125
+ """Returns the model name ``"versa-2.0"``."""
126
+ return TTS_MODEL
127
+
128
+ @property
129
+ def provider(self) -> str:
130
+ """Returns the provider name ``"Lokutor"``."""
131
+ return "Lokutor"
132
+
133
+ async def _connect_ws(self, timeout: float) -> aiohttp.ClientWebSocketResponse:
134
+ session = self._ensure_session()
135
+ # Send the API key in a header rather than the URL query string so the
136
+ # credential does not end up in exception messages, tracing, or
137
+ # intermediary access logs.
138
+ ws = await asyncio.wait_for(
139
+ session.ws_connect(
140
+ self._opts.get_ws_url(),
141
+ max_msg_size=0,
142
+ headers={"X-API-Key": self._opts.api_key},
143
+ ),
144
+ timeout,
145
+ )
146
+ return ws
147
+
148
+ async def _close_ws(self, ws: aiohttp.ClientWebSocketResponse) -> None:
149
+ await ws.close()
150
+
151
+ def _ensure_session(self) -> aiohttp.ClientSession:
152
+ if not self._session:
153
+ self._session = utils.http_context.http_session()
154
+ return self._session
155
+
156
+ def prewarm(self) -> None:
157
+ """Pre-warm the WebSocket connection pool."""
158
+ self._pool.prewarm()
159
+
160
+ def synthesize(
161
+ self,
162
+ text: str,
163
+ *,
164
+ conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
165
+ ) -> ChunkedStream:
166
+ """Synthesize text to speech in a single request.
167
+
168
+ Args:
169
+ text: The text to synthesize.
170
+ conn_options: Connection options for the request.
171
+
172
+ Returns:
173
+ A ``ChunkedStream`` that yields ``SynthesizedAudio`` events.
174
+ """
175
+ return ChunkedStream(tts=self, input_text=text, conn_options=conn_options)
176
+
177
+ def stream(
178
+ self, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
179
+ ) -> SynthesizeStream:
180
+ """Create a streaming TTS session.
181
+
182
+ Args:
183
+ conn_options: Connection options for the stream.
184
+
185
+ Returns:
186
+ A ``SynthesizeStream`` for streaming synthesis.
187
+ """
188
+ stream = SynthesizeStream(tts=self, conn_options=conn_options)
189
+ self._streams.add(stream)
190
+ return stream
191
+
192
+ async def aclose(self) -> None:
193
+ """Clean up all stream and connection resources."""
194
+ for stream in list(self._streams):
195
+ await stream.aclose()
196
+ self._streams.clear()
197
+ await self._pool.aclose()
198
+
199
+
200
+ async def _receive_audio(
201
+ ws: aiohttp.ClientWebSocketResponse,
202
+ output_emitter: tts.AudioEmitter,
203
+ *,
204
+ request_id: str,
205
+ timeout: float,
206
+ ) -> None:
207
+ """Read one request's reply: binary audio frames until the ``EOS`` text frame."""
208
+ while True:
209
+ msg = await ws.receive(timeout=timeout)
210
+ if msg.type in (
211
+ aiohttp.WSMsgType.CLOSED,
212
+ aiohttp.WSMsgType.CLOSE,
213
+ aiohttp.WSMsgType.CLOSING,
214
+ ):
215
+ raise APIStatusError(
216
+ "Lokutor connection closed unexpectedly",
217
+ request_id=request_id,
218
+ status_code=ws.close_code or -1,
219
+ body=None,
220
+ )
221
+
222
+ if msg.type == aiohttp.WSMsgType.BINARY:
223
+ output_emitter.push(msg.data)
224
+ elif msg.type == aiohttp.WSMsgType.TEXT:
225
+ if msg.data == "EOS":
226
+ return
227
+ try:
228
+ data = json.loads(msg.data)
229
+ if isinstance(data, dict) and data.get("type") == "error":
230
+ # Don't interpolate the provider's raw message
231
+ # (it may echo customer content) into the error.
232
+ raise APIError("Lokutor returned an error response")
233
+ except json.JSONDecodeError:
234
+ logger.debug("Ignoring unexpected non-JSON text frame from Lokutor")
235
+
236
+
237
+ class ChunkedStream(tts.ChunkedStream):
238
+ """Non-streaming TTS synthesis.
239
+
240
+ Sends the full text in a single WebSocket request and collects all
241
+ audio before yielding it.
242
+ """
243
+
244
+ def __init__(self, *, tts: TTS, input_text: str, conn_options: APIConnectOptions) -> None:
245
+ super().__init__(tts=tts, input_text=input_text, conn_options=conn_options)
246
+ self._tts: TTS = tts
247
+ self._opts = replace(tts._opts)
248
+
249
+ async def _run(self, output_emitter: tts.AudioEmitter) -> None:
250
+ request_id = utils.shortuuid()
251
+ output_emitter.initialize(
252
+ request_id=request_id,
253
+ sample_rate=self._opts.sample_rate,
254
+ num_channels=1,
255
+ mime_type="audio/pcm",
256
+ )
257
+
258
+ try:
259
+ async with self._tts._pool.connection(timeout=self._conn_options.timeout) as ws:
260
+ request = _build_request(self._opts, self._input_text)
261
+ await ws.send_str(json.dumps(request))
262
+ await _receive_audio(
263
+ ws,
264
+ output_emitter,
265
+ request_id=request_id,
266
+ timeout=self._conn_options.timeout,
267
+ )
268
+
269
+ output_emitter.flush()
270
+ except asyncio.TimeoutError:
271
+ raise APITimeoutError() from None
272
+ except APIError:
273
+ # Preserve typed errors raised above (APIStatusError, provider
274
+ # error) instead of collapsing them into APIConnectionError.
275
+ raise
276
+ except aiohttp.ClientResponseError as e:
277
+ raise APIStatusError(
278
+ message=e.message, status_code=e.status, request_id=None, body=None
279
+ ) from None
280
+ except Exception as e:
281
+ # Keep only the exception type, and drop the cause: an aiohttp
282
+ # transport error can carry the request URL, headers or a response
283
+ # body, and `from e` would preserve all of it on __cause__ for any
284
+ # telemetry that walks the chain.
285
+ raise APIConnectionError(type(e).__name__) from None
286
+
287
+
288
+ class SynthesizeStream(tts.SynthesizeStream):
289
+ """Streaming TTS synthesis.
290
+
291
+ The input is split into sentences and each sentence is one request on a
292
+ pooled WebSocket, so audio starts at the first sentence and every sentence
293
+ has its own prosody. A flush ends the current segment; the next text opens
294
+ a new one.
295
+ """
296
+
297
+ def __init__(self, *, tts: TTS, conn_options: APIConnectOptions) -> None:
298
+ super().__init__(tts=tts, conn_options=conn_options)
299
+ self._tts: TTS = tts
300
+ self._opts = replace(tts._opts)
301
+
302
+ async def _run(self, output_emitter: tts.AudioEmitter) -> None:
303
+ request_id = utils.shortuuid()
304
+ output_emitter.initialize(
305
+ request_id=request_id,
306
+ sample_rate=self._opts.sample_rate,
307
+ num_channels=1,
308
+ mime_type="audio/pcm",
309
+ stream=True,
310
+ )
311
+
312
+ sent_stream = self._tts._sentence_tokenizer.stream(language=self._opts.language)
313
+
314
+ async def _input_task() -> None:
315
+ try:
316
+ async for data in self._input_ch:
317
+ if isinstance(data, self._FlushSentinel):
318
+ sent_stream.flush()
319
+ continue
320
+ sent_stream.push_text(data)
321
+ finally:
322
+ sent_stream.end_input()
323
+
324
+ async def _synthesize_task() -> None:
325
+ async with self._tts._pool.connection(timeout=self._conn_options.timeout) as ws:
326
+ self._acquire_time = self._tts._pool.last_acquire_time
327
+ self._connection_reused = self._tts._pool.last_connection_reused
328
+
329
+ # A segment is open from its first sentence to the flush (or the end of the
330
+ # input) that follows it; the tokenizer gives the sentences after a flush a
331
+ # new segment id.
332
+ segment_id: str | None = None
333
+ async for ev in sent_stream:
334
+ text = ev.token.strip()
335
+ if not text:
336
+ continue
337
+ if ev.segment_id != segment_id:
338
+ if segment_id is not None:
339
+ output_emitter.end_segment()
340
+ segment_id = ev.segment_id
341
+ output_emitter.start_segment(segment_id=segment_id)
342
+
343
+ self._mark_started()
344
+ await ws.send_str(json.dumps(_build_request(self._opts, text)))
345
+ await _receive_audio(
346
+ ws,
347
+ output_emitter,
348
+ request_id=request_id,
349
+ timeout=self._conn_options.timeout,
350
+ )
351
+ output_emitter.flush()
352
+
353
+ if segment_id is not None:
354
+ output_emitter.end_segment()
355
+
356
+ tasks = [
357
+ asyncio.create_task(_input_task()),
358
+ asyncio.create_task(_synthesize_task()),
359
+ ]
360
+ try:
361
+ await asyncio.gather(*tasks)
362
+ except asyncio.TimeoutError:
363
+ raise APITimeoutError() from None
364
+ except APIError:
365
+ # Preserve typed errors raised above instead of collapsing them
366
+ # into a generic APIConnectionError.
367
+ raise
368
+ except aiohttp.ClientResponseError as e:
369
+ raise APIStatusError(
370
+ message=e.message, status_code=e.status, request_id=None, body=None
371
+ ) from None
372
+ except Exception as e:
373
+ # Keep only the exception type, and drop the cause: an aiohttp
374
+ # transport error can carry the request URL, headers or a response
375
+ # body, and `from e` would preserve all of it on __cause__ for any
376
+ # telemetry that walks the chain.
377
+ raise APIConnectionError(type(e).__name__) from None
378
+ finally:
379
+ await utils.aio.cancel_and_wait(*tasks)
380
+ await sent_stream.aclose()
381
+
382
+
383
+ def _build_request(opts: _TTSOptions, text: str) -> dict[str, Any]:
384
+ request: dict[str, Any] = {
385
+ "text": text,
386
+ "voice": opts.voice,
387
+ }
388
+ if opts.language:
389
+ request["language"] = LanguageCode(opts.language).language
390
+ return request
@@ -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.8.12"
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "livekit-plugins-lokutor"
7
+ dynamic = ["version"]
8
+ description = "Lokutor speech-to-text and text-to-speech plugin for LiveKit agents"
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 = ["webrtc", "realtime", "audio", "livekit", "lokutor", "STT", "TTS", "voice"]
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.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3 :: Only",
23
+ ]
24
+ dependencies = ["livekit-agents>=1.8.12"]
25
+
26
+ [project.urls]
27
+ Documentation = "https://docs.livekit.io"
28
+ Website = "https://livekit.io/"
29
+ Source = "https://github.com/livekit/agents"
30
+
31
+ [tool.hatch.version]
32
+ path = "livekit/plugins/lokutor/version.py"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["livekit"]
36
+
37
+ [tool.hatch.build.targets.sdist]
38
+ include = ["/livekit"]
39
+
40
+ [tool.uv]
41
+ exclude-newer = "7 days"
42
+ exclude-newer-package = { livekit-agents = "0 days" }
43
+