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.
- livekit_plugins_lokutor-1.8.12/.gitignore +180 -0
- livekit_plugins_lokutor-1.8.12/PKG-INFO +95 -0
- livekit_plugins_lokutor-1.8.12/README.md +73 -0
- livekit_plugins_lokutor-1.8.12/livekit/plugins/lokutor/__init__.py +30 -0
- livekit_plugins_lokutor-1.8.12/livekit/plugins/lokutor/log.py +3 -0
- livekit_plugins_lokutor-1.8.12/livekit/plugins/lokutor/models.py +35 -0
- livekit_plugins_lokutor-1.8.12/livekit/plugins/lokutor/py.typed +0 -0
- livekit_plugins_lokutor-1.8.12/livekit/plugins/lokutor/stt.py +449 -0
- livekit_plugins_lokutor-1.8.12/livekit/plugins/lokutor/tts.py +390 -0
- livekit_plugins_lokutor-1.8.12/livekit/plugins/lokutor/version.py +15 -0
- livekit_plugins_lokutor-1.8.12/pyproject.toml +43 -0
|
@@ -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,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"
|
|
File without changes
|
|
@@ -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
|
+
|