livekit-plugins-nabrah 1.8.6__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_nabrah-1.8.6/.gitignore +180 -0
- livekit_plugins_nabrah-1.8.6/PKG-INFO +198 -0
- livekit_plugins_nabrah-1.8.6/README.md +176 -0
- livekit_plugins_nabrah-1.8.6/livekit/plugins/nabrah/__init__.py +31 -0
- livekit_plugins_nabrah-1.8.6/livekit/plugins/nabrah/log.py +17 -0
- livekit_plugins_nabrah-1.8.6/livekit/plugins/nabrah/py.typed +0 -0
- livekit_plugins_nabrah-1.8.6/livekit/plugins/nabrah/stt.py +654 -0
- livekit_plugins_nabrah-1.8.6/livekit/plugins/nabrah/version.py +15 -0
- livekit_plugins_nabrah-1.8.6/pyproject.toml +53 -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,198 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: livekit-plugins-nabrah
|
|
3
|
+
Version: 1.8.6
|
|
4
|
+
Summary: LiveKit Agents plugin for Nabrah Arabic speech-to-text
|
|
5
|
+
Project-URL: Documentation, https://docs.livekit.io
|
|
6
|
+
Project-URL: Website, https://nabrah.ai
|
|
7
|
+
Project-URL: Source, https://github.com/livekit/agents
|
|
8
|
+
Author-email: LiveKit <hello@livekit.io>
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
Keywords: arabic,audio,livekit,livekit-agents,nabrah,realtime,speech-to-text,streaming,stt,webrtc,websocket
|
|
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.10
|
|
16
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: aiohttp
|
|
20
|
+
Requires-Dist: livekit-agents>=1.8.6
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# Nabrah plugin for LiveKit Agents
|
|
24
|
+
|
|
25
|
+
Support for [Nabrah](https://nabrah.ai/) Speech-to-Text in LiveKit Agents, with
|
|
26
|
+
client-side end-of-turn detection tuned for Arabic conversation.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install livekit-plugins-nabrah
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Pre-requisites
|
|
35
|
+
|
|
36
|
+
You'll need an API key from Nabrah. It can be set as an environment variable:
|
|
37
|
+
`NABRAH_API_KEY`.
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
Use Nabrah STT in an `AgentSession`:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from livekit.agents import AgentSession
|
|
45
|
+
from livekit.plugins import nabrah
|
|
46
|
+
|
|
47
|
+
session = AgentSession(
|
|
48
|
+
stt=nabrah.STT(
|
|
49
|
+
recognition_model="eot_nabrah",
|
|
50
|
+
language="ar-SA",
|
|
51
|
+
end_of_turn_confirm_delay_seconds=0.4,
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Turn detection
|
|
57
|
+
|
|
58
|
+
`recognition_model="eot_nabrah"` emits the end-of-turn signal the plugin uses to
|
|
59
|
+
close a turn. `end_of_turn_confirm_delay_seconds` is how long it waits after that
|
|
60
|
+
signal before committing. Speaking again inside the window keeps the turn open.
|
|
61
|
+
`max_transcript_inactivity_seconds` optionally commits after that many seconds
|
|
62
|
+
without new transcript text. It is disabled by default because it measures
|
|
63
|
+
recognizer inactivity, not acoustic silence, and a provider delay could otherwise
|
|
64
|
+
split continuous speech.
|
|
65
|
+
|
|
66
|
+
Nabrah's unnamed model (`recognition_model=""`) is more accurate but emits no
|
|
67
|
+
end-of-turn signal. When using it, configure another turn detector or explicitly
|
|
68
|
+
set `max_transcript_inactivity_seconds` as a fallback heuristic.
|
|
69
|
+
|
|
70
|
+
### Word boosting
|
|
71
|
+
|
|
72
|
+
Bias recognition toward terms the model gets wrong:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
stt = nabrah.STT(
|
|
76
|
+
priority_words=["مستشفى الملك فيصل التخصصي", "رقم الهوية الوطنية"],
|
|
77
|
+
priority_words_strength=0.5,
|
|
78
|
+
)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`0.5` is the recommended strength. Higher values make boosted terms appear in
|
|
82
|
+
places they were not said, so keep the list to terms your callers actually say
|
|
83
|
+
and add one only after hearing it come out wrong. Multi-word phrases are boosted
|
|
84
|
+
as a unit.
|
|
85
|
+
|
|
86
|
+
#### Loading terms from a file
|
|
87
|
+
|
|
88
|
+
For anything beyond a handful of terms, keep them in a JSON file so they can be
|
|
89
|
+
edited without touching code. Create `boosting.json` next to your agent:
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"boost_threshold": 0.5,
|
|
94
|
+
"words": [
|
|
95
|
+
"مستشفى الملك فيصل التخصصي",
|
|
96
|
+
"رقم الهوية الوطنية"
|
|
97
|
+
]
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Load it at startup and pass it to the plugin:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
import json
|
|
105
|
+
import pathlib
|
|
106
|
+
|
|
107
|
+
from livekit.agents import AgentSession
|
|
108
|
+
from livekit.plugins import nabrah
|
|
109
|
+
|
|
110
|
+
boosting = json.loads(
|
|
111
|
+
pathlib.Path(__file__).with_name("boosting.json").read_text(encoding="utf-8")
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
session = AgentSession(
|
|
115
|
+
stt=nabrah.STT(
|
|
116
|
+
recognition_model="eot_nabrah",
|
|
117
|
+
language="ar-SA",
|
|
118
|
+
end_of_turn_confirm_delay_seconds=0.4,
|
|
119
|
+
priority_words=boosting["words"],
|
|
120
|
+
priority_words_strength=boosting["boost_threshold"],
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Read the file with `encoding="utf-8"`. Without it, Arabic terms fail to load on
|
|
126
|
+
platforms that default to a different encoding.
|
|
127
|
+
|
|
128
|
+
## Full example
|
|
129
|
+
|
|
130
|
+
A complete agent using Nabrah STT with word boosting loaded from a file:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
import json
|
|
134
|
+
import pathlib
|
|
135
|
+
|
|
136
|
+
from dotenv import load_dotenv
|
|
137
|
+
from livekit import agents
|
|
138
|
+
from livekit.agents import (
|
|
139
|
+
Agent,
|
|
140
|
+
AgentServer,
|
|
141
|
+
AgentSession,
|
|
142
|
+
JobContext,
|
|
143
|
+
TurnHandlingOptions,
|
|
144
|
+
inference,
|
|
145
|
+
)
|
|
146
|
+
from livekit.plugins import nabrah
|
|
147
|
+
|
|
148
|
+
load_dotenv()
|
|
149
|
+
|
|
150
|
+
boosting = json.loads(
|
|
151
|
+
pathlib.Path(__file__).with_name("boosting.json").read_text(encoding="utf-8")
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
server = AgentServer()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class Assistant(Agent):
|
|
158
|
+
def __init__(self) -> None:
|
|
159
|
+
super().__init__(instructions="You are a helpful voice assistant.")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@server.rtc_session()
|
|
163
|
+
async def entrypoint(ctx: JobContext) -> None:
|
|
164
|
+
session = AgentSession(
|
|
165
|
+
stt=nabrah.STT(
|
|
166
|
+
recognition_model="eot_nabrah",
|
|
167
|
+
language="ar-SA",
|
|
168
|
+
end_of_turn_confirm_delay_seconds=0.4,
|
|
169
|
+
max_transcript_inactivity_seconds=1.5,
|
|
170
|
+
priority_words=boosting["words"],
|
|
171
|
+
priority_words_strength=boosting["boost_threshold"],
|
|
172
|
+
),
|
|
173
|
+
llm=inference.LLM("openai/gpt-4.1-mini"),
|
|
174
|
+
tts=inference.TTS("cartesia/sonic-3"),
|
|
175
|
+
turn_handling=TurnHandlingOptions(
|
|
176
|
+
turn_detection="stt",
|
|
177
|
+
endpointing={"min_delay": 0},
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
await session.start(agent=Assistant(), room=ctx.room)
|
|
182
|
+
await ctx.connect()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
if __name__ == "__main__":
|
|
186
|
+
agents.cli.run_app(server)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Parameters
|
|
190
|
+
|
|
191
|
+
| Parameter | Default | Description |
|
|
192
|
+
| --- | --- | --- |
|
|
193
|
+
| `recognition_model` | `"eot_nabrah"` | `"eot_nabrah"` emits the end-of-turn signal used for turn detection. `""` selects the default model, which is more accurate but emits no signal. |
|
|
194
|
+
| `end_of_turn_confirm_delay_seconds` | `0.4` | Hold after an end-of-turn signal before committing the turn. `None` commits immediately. |
|
|
195
|
+
| `max_transcript_inactivity_seconds` | `None` | Optional fallback that finalizes after no new transcript text. This does not measure acoustic silence. |
|
|
196
|
+
| `priority_words` | `[]` | Terms to bias recognition toward. |
|
|
197
|
+
| `priority_words_strength` | `0.5` | How strongly to bias. |
|
|
198
|
+
| `api_key` | `NABRAH_API_KEY` | API key, if not set in the environment. |
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# Nabrah plugin for LiveKit Agents
|
|
2
|
+
|
|
3
|
+
Support for [Nabrah](https://nabrah.ai/) Speech-to-Text in LiveKit Agents, with
|
|
4
|
+
client-side end-of-turn detection tuned for Arabic conversation.
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install livekit-plugins-nabrah
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Pre-requisites
|
|
13
|
+
|
|
14
|
+
You'll need an API key from Nabrah. It can be set as an environment variable:
|
|
15
|
+
`NABRAH_API_KEY`.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
Use Nabrah STT in an `AgentSession`:
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from livekit.agents import AgentSession
|
|
23
|
+
from livekit.plugins import nabrah
|
|
24
|
+
|
|
25
|
+
session = AgentSession(
|
|
26
|
+
stt=nabrah.STT(
|
|
27
|
+
recognition_model="eot_nabrah",
|
|
28
|
+
language="ar-SA",
|
|
29
|
+
end_of_turn_confirm_delay_seconds=0.4,
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Turn detection
|
|
35
|
+
|
|
36
|
+
`recognition_model="eot_nabrah"` emits the end-of-turn signal the plugin uses to
|
|
37
|
+
close a turn. `end_of_turn_confirm_delay_seconds` is how long it waits after that
|
|
38
|
+
signal before committing. Speaking again inside the window keeps the turn open.
|
|
39
|
+
`max_transcript_inactivity_seconds` optionally commits after that many seconds
|
|
40
|
+
without new transcript text. It is disabled by default because it measures
|
|
41
|
+
recognizer inactivity, not acoustic silence, and a provider delay could otherwise
|
|
42
|
+
split continuous speech.
|
|
43
|
+
|
|
44
|
+
Nabrah's unnamed model (`recognition_model=""`) is more accurate but emits no
|
|
45
|
+
end-of-turn signal. When using it, configure another turn detector or explicitly
|
|
46
|
+
set `max_transcript_inactivity_seconds` as a fallback heuristic.
|
|
47
|
+
|
|
48
|
+
### Word boosting
|
|
49
|
+
|
|
50
|
+
Bias recognition toward terms the model gets wrong:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
stt = nabrah.STT(
|
|
54
|
+
priority_words=["مستشفى الملك فيصل التخصصي", "رقم الهوية الوطنية"],
|
|
55
|
+
priority_words_strength=0.5,
|
|
56
|
+
)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`0.5` is the recommended strength. Higher values make boosted terms appear in
|
|
60
|
+
places they were not said, so keep the list to terms your callers actually say
|
|
61
|
+
and add one only after hearing it come out wrong. Multi-word phrases are boosted
|
|
62
|
+
as a unit.
|
|
63
|
+
|
|
64
|
+
#### Loading terms from a file
|
|
65
|
+
|
|
66
|
+
For anything beyond a handful of terms, keep them in a JSON file so they can be
|
|
67
|
+
edited without touching code. Create `boosting.json` next to your agent:
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"boost_threshold": 0.5,
|
|
72
|
+
"words": [
|
|
73
|
+
"مستشفى الملك فيصل التخصصي",
|
|
74
|
+
"رقم الهوية الوطنية"
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Load it at startup and pass it to the plugin:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
import json
|
|
83
|
+
import pathlib
|
|
84
|
+
|
|
85
|
+
from livekit.agents import AgentSession
|
|
86
|
+
from livekit.plugins import nabrah
|
|
87
|
+
|
|
88
|
+
boosting = json.loads(
|
|
89
|
+
pathlib.Path(__file__).with_name("boosting.json").read_text(encoding="utf-8")
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
session = AgentSession(
|
|
93
|
+
stt=nabrah.STT(
|
|
94
|
+
recognition_model="eot_nabrah",
|
|
95
|
+
language="ar-SA",
|
|
96
|
+
end_of_turn_confirm_delay_seconds=0.4,
|
|
97
|
+
priority_words=boosting["words"],
|
|
98
|
+
priority_words_strength=boosting["boost_threshold"],
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Read the file with `encoding="utf-8"`. Without it, Arabic terms fail to load on
|
|
104
|
+
platforms that default to a different encoding.
|
|
105
|
+
|
|
106
|
+
## Full example
|
|
107
|
+
|
|
108
|
+
A complete agent using Nabrah STT with word boosting loaded from a file:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
import json
|
|
112
|
+
import pathlib
|
|
113
|
+
|
|
114
|
+
from dotenv import load_dotenv
|
|
115
|
+
from livekit import agents
|
|
116
|
+
from livekit.agents import (
|
|
117
|
+
Agent,
|
|
118
|
+
AgentServer,
|
|
119
|
+
AgentSession,
|
|
120
|
+
JobContext,
|
|
121
|
+
TurnHandlingOptions,
|
|
122
|
+
inference,
|
|
123
|
+
)
|
|
124
|
+
from livekit.plugins import nabrah
|
|
125
|
+
|
|
126
|
+
load_dotenv()
|
|
127
|
+
|
|
128
|
+
boosting = json.loads(
|
|
129
|
+
pathlib.Path(__file__).with_name("boosting.json").read_text(encoding="utf-8")
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
server = AgentServer()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class Assistant(Agent):
|
|
136
|
+
def __init__(self) -> None:
|
|
137
|
+
super().__init__(instructions="You are a helpful voice assistant.")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@server.rtc_session()
|
|
141
|
+
async def entrypoint(ctx: JobContext) -> None:
|
|
142
|
+
session = AgentSession(
|
|
143
|
+
stt=nabrah.STT(
|
|
144
|
+
recognition_model="eot_nabrah",
|
|
145
|
+
language="ar-SA",
|
|
146
|
+
end_of_turn_confirm_delay_seconds=0.4,
|
|
147
|
+
max_transcript_inactivity_seconds=1.5,
|
|
148
|
+
priority_words=boosting["words"],
|
|
149
|
+
priority_words_strength=boosting["boost_threshold"],
|
|
150
|
+
),
|
|
151
|
+
llm=inference.LLM("openai/gpt-4.1-mini"),
|
|
152
|
+
tts=inference.TTS("cartesia/sonic-3"),
|
|
153
|
+
turn_handling=TurnHandlingOptions(
|
|
154
|
+
turn_detection="stt",
|
|
155
|
+
endpointing={"min_delay": 0},
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
await session.start(agent=Assistant(), room=ctx.room)
|
|
160
|
+
await ctx.connect()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
if __name__ == "__main__":
|
|
164
|
+
agents.cli.run_app(server)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Parameters
|
|
168
|
+
|
|
169
|
+
| Parameter | Default | Description |
|
|
170
|
+
| --- | --- | --- |
|
|
171
|
+
| `recognition_model` | `"eot_nabrah"` | `"eot_nabrah"` emits the end-of-turn signal used for turn detection. `""` selects the default model, which is more accurate but emits no signal. |
|
|
172
|
+
| `end_of_turn_confirm_delay_seconds` | `0.4` | Hold after an end-of-turn signal before committing the turn. `None` commits immediately. |
|
|
173
|
+
| `max_transcript_inactivity_seconds` | `None` | Optional fallback that finalizes after no new transcript text. This does not measure acoustic silence. |
|
|
174
|
+
| `priority_words` | `[]` | Terms to bias recognition toward. |
|
|
175
|
+
| `priority_words_strength` | `0.5` | How strongly to bias. |
|
|
176
|
+
| `api_key` | `NABRAH_API_KEY` | API key, if not set in the environment. |
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Copyright 2026 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
|
+
"""Nabrah speech-to-text plugin for LiveKit Agents."""
|
|
16
|
+
|
|
17
|
+
from livekit.agents import Plugin
|
|
18
|
+
|
|
19
|
+
from .log import logger
|
|
20
|
+
from .stt import STT, NabrahRecognitionModel, SpeechStream
|
|
21
|
+
from .version import __version__
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NabrahPlugin(Plugin):
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
super().__init__(__name__, __version__, __package__, logger)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
Plugin.register_plugin(NabrahPlugin())
|
|
30
|
+
|
|
31
|
+
__all__ = ["STT", "NabrahRecognitionModel", "SpeechStream", "__version__"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Copyright 2026 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
|
+
import logging
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("livekit.plugins.nabrah")
|
|
File without changes
|
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
# Copyright 2026 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
|
+
"""Nabrah STT plugin for LiveKit Agents.
|
|
16
|
+
|
|
17
|
+
The backend relays recognizer output with minimal interpretation, so the plugin
|
|
18
|
+
handles token stripping, EOT detection, debounce, cross-utterance accumulation,
|
|
19
|
+
word conversion, and audio-position tracking.
|
|
20
|
+
|
|
21
|
+
Wire contract (server -> client):
|
|
22
|
+
{"type": "ready"}
|
|
23
|
+
{"type": "error", "message": ...}
|
|
24
|
+
{"type": "transcript", "text": ..., "is_final": bool,
|
|
25
|
+
"audio_processed": float}
|
|
26
|
+
|
|
27
|
+
Two facts drive the whole design:
|
|
28
|
+
|
|
29
|
+
- `text` is cumulative *within one recognizer utterance* and restarts from
|
|
30
|
+
scratch after every `is_final`. `is_final` is utterance-level, not
|
|
31
|
+
turn-level one spoken sentence routinely produces a dozen so a final is
|
|
32
|
+
only a diff-cursor reset here, never a turn boundary.
|
|
33
|
+
- `audio_processed` is cumulative seconds of audio the recognizer has actually
|
|
34
|
+
consumed. It is the authoritative audio clock; a send-side count runs ahead
|
|
35
|
+
of realtime when input arrives in bursts. The key may be absent on older
|
|
36
|
+
backends, so the local counter stays as a fallback.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import asyncio
|
|
42
|
+
import json
|
|
43
|
+
import math
|
|
44
|
+
import os
|
|
45
|
+
import time
|
|
46
|
+
from typing import Any, Literal
|
|
47
|
+
|
|
48
|
+
import aiohttp
|
|
49
|
+
|
|
50
|
+
from livekit import rtc
|
|
51
|
+
from livekit.agents import (
|
|
52
|
+
DEFAULT_API_CONNECT_OPTIONS,
|
|
53
|
+
APIConnectionError,
|
|
54
|
+
APIConnectOptions,
|
|
55
|
+
APIError,
|
|
56
|
+
APIStatusError,
|
|
57
|
+
stt,
|
|
58
|
+
utils,
|
|
59
|
+
)
|
|
60
|
+
from livekit.agents.language import LanguageCode
|
|
61
|
+
from livekit.agents.types import NOT_GIVEN, NotGivenOr, TimedString
|
|
62
|
+
from livekit.agents.utils import is_given
|
|
63
|
+
|
|
64
|
+
from .log import logger
|
|
65
|
+
|
|
66
|
+
DEFAULT_BASE_URL = "wss://api.nabrah.ai/api/ext/stt/ws"
|
|
67
|
+
|
|
68
|
+
NabrahRecognitionModel = Literal["eot_nabrah", ""]
|
|
69
|
+
|
|
70
|
+
EOT_TOKEN = "<eot>"
|
|
71
|
+
EOT_PUNCTUATION = (".", "?", "!", "؟")
|
|
72
|
+
_NO_SPACE_BEFORE = frozenset(".,?!:;،؛؟")
|
|
73
|
+
|
|
74
|
+
_WATCHDOG_POLL_SECONDS = 0.1
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _strip_and_detect_eot(new_text: str) -> tuple[str, bool]:
|
|
78
|
+
"""Strip <eot> from a transcript diff and flag an end-of-turn candidate.
|
|
79
|
+
|
|
80
|
+
Detection runs on the raw diff so a trailing token isn't lost by its own
|
|
81
|
+
removal. The token becomes a SPACE, never "" the recognizer doesn't
|
|
82
|
+
reliably pad it, and stripping to empty glues the neighbours together
|
|
83
|
+
("الله" + "تعالى" -> "اللهتعالى").
|
|
84
|
+
"""
|
|
85
|
+
stripped_new_text = new_text.rstrip()
|
|
86
|
+
is_candidate = stripped_new_text.endswith(EOT_TOKEN) or (
|
|
87
|
+
stripped_new_text[-1:] in EOT_PUNCTUATION
|
|
88
|
+
)
|
|
89
|
+
return new_text.replace(EOT_TOKEN, " "), is_candidate
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _normalize_whitespace(text: str) -> str:
|
|
93
|
+
return " ".join(text.split())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _append_text(existing: str, new: str) -> str:
|
|
97
|
+
"""Join transcript fragments without inserting spaces before punctuation."""
|
|
98
|
+
head = new.lstrip()[:1]
|
|
99
|
+
separator = "" if not existing or head in _NO_SPACE_BEFORE else " "
|
|
100
|
+
return _normalize_whitespace(existing + separator + new)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _word_text(w: dict[str, Any]) -> str:
|
|
104
|
+
value = w.get("word", "")
|
|
105
|
+
if not isinstance(value, str):
|
|
106
|
+
raise ValueError("nabrah word text must be a string")
|
|
107
|
+
return value.replace(EOT_TOKEN, "").strip()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _finite_number(value: object, *, field: str) -> float:
|
|
111
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
112
|
+
raise ValueError(f"nabrah {field} must be a number")
|
|
113
|
+
number = float(value)
|
|
114
|
+
if not math.isfinite(number) or number < 0:
|
|
115
|
+
raise ValueError(f"nabrah {field} must be finite and non-negative")
|
|
116
|
+
return number
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _timed_word(w: dict[str, Any], offset: float) -> TimedString:
|
|
120
|
+
"""One backend word entry as a TimedString. Timestamps arrive in ms."""
|
|
121
|
+
start_time = _finite_number(w.get("start_time", 0), field="word start_time")
|
|
122
|
+
end_time = _finite_number(w.get("end_time", 0), field="word end_time")
|
|
123
|
+
if end_time < start_time:
|
|
124
|
+
raise ValueError("nabrah word end_time must not precede start_time")
|
|
125
|
+
|
|
126
|
+
confidence_value = w.get("confidence", NOT_GIVEN)
|
|
127
|
+
confidence = (
|
|
128
|
+
NOT_GIVEN
|
|
129
|
+
if confidence_value is None or not is_given(confidence_value)
|
|
130
|
+
else _finite_number(confidence_value, field="word confidence")
|
|
131
|
+
)
|
|
132
|
+
return TimedString(
|
|
133
|
+
_word_text(w),
|
|
134
|
+
start_time=start_time / 1000.0 + offset,
|
|
135
|
+
end_time=end_time / 1000.0 + offset,
|
|
136
|
+
confidence=confidence,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class STT(stt.STT):
|
|
141
|
+
def __init__(
|
|
142
|
+
self,
|
|
143
|
+
*,
|
|
144
|
+
recognition_model: NabrahRecognitionModel | str = "eot_nabrah",
|
|
145
|
+
base_url: NotGivenOr[str] = NOT_GIVEN,
|
|
146
|
+
api_key: NotGivenOr[str] = NOT_GIVEN,
|
|
147
|
+
language: str = "ar-SA",
|
|
148
|
+
end_of_utterance_silence_ms: int = -1,
|
|
149
|
+
disable_number_normalization: bool = False,
|
|
150
|
+
priority_words: list[str] | None = None,
|
|
151
|
+
priority_words_strength: float = 0.5,
|
|
152
|
+
max_transcript_inactivity_seconds: float | None = None,
|
|
153
|
+
end_of_turn_confirm_delay_seconds: float | None = 0.4,
|
|
154
|
+
http_session: aiohttp.ClientSession | None = None,
|
|
155
|
+
) -> None:
|
|
156
|
+
super().__init__(
|
|
157
|
+
capabilities=stt.STTCapabilities(
|
|
158
|
+
streaming=True,
|
|
159
|
+
interim_results=True,
|
|
160
|
+
aligned_transcript=False,
|
|
161
|
+
offline_recognize=False,
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
nabrah_api_key = api_key if is_given(api_key) else os.environ.get("NABRAH_API_KEY")
|
|
165
|
+
if not nabrah_api_key:
|
|
166
|
+
raise ValueError(
|
|
167
|
+
"Nabrah API key is required, either as argument or by setting "
|
|
168
|
+
"the NABRAH_API_KEY environment variable",
|
|
169
|
+
)
|
|
170
|
+
self._api_key = nabrah_api_key
|
|
171
|
+
self._base_url = (
|
|
172
|
+
base_url
|
|
173
|
+
if is_given(base_url)
|
|
174
|
+
else os.environ.get(
|
|
175
|
+
"NABRAH_STT_URL",
|
|
176
|
+
DEFAULT_BASE_URL,
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
self._recognition_model = recognition_model
|
|
181
|
+
self._language = language
|
|
182
|
+
self._end_of_utterance_silence_ms = end_of_utterance_silence_ms
|
|
183
|
+
self._disable_number_normalization = disable_number_normalization
|
|
184
|
+
self._priority_words = priority_words or []
|
|
185
|
+
self._priority_words_strength = priority_words_strength
|
|
186
|
+
self._max_transcript_inactivity_seconds = max_transcript_inactivity_seconds
|
|
187
|
+
self._end_of_turn_confirm_delay_seconds = end_of_turn_confirm_delay_seconds
|
|
188
|
+
self._session = http_session
|
|
189
|
+
self._label = "nabrah.STT (nabrah-stt-v1)"
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def model(self) -> str:
|
|
193
|
+
return "nabrah-stt-v1"
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def provider(self) -> str:
|
|
197
|
+
return "nabrah"
|
|
198
|
+
|
|
199
|
+
def _ensure_session(self) -> aiohttp.ClientSession:
|
|
200
|
+
if not self._session:
|
|
201
|
+
self._session = utils.http_context.http_session()
|
|
202
|
+
return self._session
|
|
203
|
+
|
|
204
|
+
async def _recognize_impl(
|
|
205
|
+
self,
|
|
206
|
+
buffer: utils.AudioBuffer,
|
|
207
|
+
*,
|
|
208
|
+
language: NotGivenOr[str] = NOT_GIVEN,
|
|
209
|
+
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
|
|
210
|
+
) -> stt.SpeechEvent:
|
|
211
|
+
raise NotImplementedError("nabrah STT does not support single-shot recognition")
|
|
212
|
+
|
|
213
|
+
def stream(
|
|
214
|
+
self,
|
|
215
|
+
*,
|
|
216
|
+
language: NotGivenOr[str] = NOT_GIVEN,
|
|
217
|
+
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
|
|
218
|
+
) -> stt.RecognizeStream:
|
|
219
|
+
lang = language if is_given(language) else self._language
|
|
220
|
+
return SpeechStream(
|
|
221
|
+
stt_instance=self,
|
|
222
|
+
conn_options=conn_options,
|
|
223
|
+
language=lang,
|
|
224
|
+
http_session=self._ensure_session(),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class SpeechStream(stt.SpeechStream):
|
|
229
|
+
_utt_raw: str = ""
|
|
230
|
+
_utt_clean: str = ""
|
|
231
|
+
_utt_closed: bool = False
|
|
232
|
+
_utt_flushed_clean: str = ""
|
|
233
|
+
_utt_flushed_chars: int = 0
|
|
234
|
+
_input_done: bool = False
|
|
235
|
+
_turn_words: tuple[TimedString, ...] = ()
|
|
236
|
+
_utt_words: tuple[TimedString, ...] = ()
|
|
237
|
+
_utt_flushed_words: int = 0
|
|
238
|
+
_utt_raw_seen: int = 0
|
|
239
|
+
|
|
240
|
+
def __init__(
|
|
241
|
+
self,
|
|
242
|
+
*,
|
|
243
|
+
stt_instance: STT,
|
|
244
|
+
conn_options: APIConnectOptions,
|
|
245
|
+
language: str,
|
|
246
|
+
http_session: aiohttp.ClientSession,
|
|
247
|
+
) -> None:
|
|
248
|
+
super().__init__(stt=stt_instance, conn_options=conn_options, sample_rate=16000)
|
|
249
|
+
self._stt: STT = stt_instance
|
|
250
|
+
self._language = LanguageCode(language)
|
|
251
|
+
self._session = http_session
|
|
252
|
+
|
|
253
|
+
self._is_speaking = False
|
|
254
|
+
|
|
255
|
+
self._turn_text = ""
|
|
256
|
+
self._utt_clean = ""
|
|
257
|
+
self._utt_raw = ""
|
|
258
|
+
self._utt_closed = False
|
|
259
|
+
self._utt_flushed_clean = ""
|
|
260
|
+
self._utt_flushed_chars = 0
|
|
261
|
+
self._turn_words = ()
|
|
262
|
+
self._utt_words = ()
|
|
263
|
+
self._utt_flushed_words = 0
|
|
264
|
+
self._utt_raw_seen = 0
|
|
265
|
+
|
|
266
|
+
self._segment_start_time: float = 0.0
|
|
267
|
+
self._segment_end_time: float = 0.0
|
|
268
|
+
self._request_id: str = ""
|
|
269
|
+
|
|
270
|
+
self._last_progress_at: float = 0.0
|
|
271
|
+
self._pending_eot_at: float | None = None
|
|
272
|
+
|
|
273
|
+
self._latest_audio_processed: float | None = None
|
|
274
|
+
self._audio_position: float = 0.0
|
|
275
|
+
self._reported_audio_position: float = 0.0
|
|
276
|
+
self._last_message_position: float = 0.0
|
|
277
|
+
|
|
278
|
+
def _config_frame(self) -> dict[str, Any]:
|
|
279
|
+
return {
|
|
280
|
+
"api_key": self._stt._api_key,
|
|
281
|
+
"recognition_model": self._stt._recognition_model,
|
|
282
|
+
"end_of_utterance_silence_ms": self._stt._end_of_utterance_silence_ms,
|
|
283
|
+
"disable_number_normalization": self._stt._disable_number_normalization,
|
|
284
|
+
"priority_words": self._stt._priority_words,
|
|
285
|
+
"priority_words_strength": self._stt._priority_words_strength,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async def _connect_ws(self) -> aiohttp.ClientWebSocketResponse:
|
|
289
|
+
try:
|
|
290
|
+
return await asyncio.wait_for(
|
|
291
|
+
self._session.ws_connect(self._stt._base_url),
|
|
292
|
+
self._conn_options.timeout,
|
|
293
|
+
)
|
|
294
|
+
except (
|
|
295
|
+
aiohttp.ClientConnectorError,
|
|
296
|
+
aiohttp.WSServerHandshakeError,
|
|
297
|
+
asyncio.TimeoutError,
|
|
298
|
+
) as e:
|
|
299
|
+
raise APIConnectionError("failed to connect to nabrah STT") from e
|
|
300
|
+
|
|
301
|
+
def _stream_failure(self, message: str) -> APIConnectionError:
|
|
302
|
+
return APIConnectionError(message, retryable=self._audio_position == 0.0)
|
|
303
|
+
|
|
304
|
+
async def _run(self) -> None:
|
|
305
|
+
self._latest_audio_processed = None
|
|
306
|
+
self._audio_position = 0.0
|
|
307
|
+
self._reported_audio_position = 0.0
|
|
308
|
+
self._last_message_position = 0.0
|
|
309
|
+
self._input_done = False
|
|
310
|
+
|
|
311
|
+
ws = await self._connect_ws()
|
|
312
|
+
try:
|
|
313
|
+
try:
|
|
314
|
+
await ws.send_str(json.dumps(self._config_frame()))
|
|
315
|
+
except Exception as e:
|
|
316
|
+
raise APIConnectionError(
|
|
317
|
+
f"nabrah STT configuration send failed ({type(e).__name__})"
|
|
318
|
+
) from None
|
|
319
|
+
|
|
320
|
+
async def _await_ready() -> None:
|
|
321
|
+
while True:
|
|
322
|
+
try:
|
|
323
|
+
msg = await ws.receive()
|
|
324
|
+
except Exception as e:
|
|
325
|
+
raise APIConnectionError(
|
|
326
|
+
f"nabrah STT ready handshake failed ({type(e).__name__})"
|
|
327
|
+
) from None
|
|
328
|
+
if msg.type != aiohttp.WSMsgType.TEXT:
|
|
329
|
+
raise APIConnectionError("nabrah STT closed before ready")
|
|
330
|
+
try:
|
|
331
|
+
payload = json.loads(msg.data)
|
|
332
|
+
except (json.JSONDecodeError, TypeError):
|
|
333
|
+
raise APIConnectionError(
|
|
334
|
+
"nabrah STT sent an invalid ready response"
|
|
335
|
+
) from None
|
|
336
|
+
if not isinstance(payload, dict):
|
|
337
|
+
raise APIConnectionError("nabrah STT sent an invalid ready response")
|
|
338
|
+
if payload.get("type") == "error":
|
|
339
|
+
raise APIStatusError(
|
|
340
|
+
"nabrah STT rejected the configuration",
|
|
341
|
+
status_code=-1,
|
|
342
|
+
retryable=False,
|
|
343
|
+
)
|
|
344
|
+
if payload.get("type") == "ready":
|
|
345
|
+
return
|
|
346
|
+
|
|
347
|
+
try:
|
|
348
|
+
await asyncio.wait_for(_await_ready(), self._conn_options.timeout)
|
|
349
|
+
except asyncio.TimeoutError as e:
|
|
350
|
+
raise APIConnectionError("nabrah STT never sent ready") from e
|
|
351
|
+
|
|
352
|
+
watchdog_task = asyncio.create_task(self._eot_watchdog())
|
|
353
|
+
send_task = asyncio.create_task(self._send_task(ws))
|
|
354
|
+
recv_task = asyncio.create_task(self._recv_task(ws))
|
|
355
|
+
try:
|
|
356
|
+
await asyncio.gather(send_task, recv_task)
|
|
357
|
+
finally:
|
|
358
|
+
watchdog_task.cancel()
|
|
359
|
+
await utils.aio.gracefully_cancel(send_task, recv_task, watchdog_task)
|
|
360
|
+
if (
|
|
361
|
+
self._current_text()
|
|
362
|
+
or self._is_speaking
|
|
363
|
+
or self._audio_clock() > self._reported_audio_position
|
|
364
|
+
):
|
|
365
|
+
self._flush_eos()
|
|
366
|
+
finally:
|
|
367
|
+
await ws.close()
|
|
368
|
+
|
|
369
|
+
async def _eot_watchdog(self) -> None:
|
|
370
|
+
inactivity_timeout = self._stt._max_transcript_inactivity_seconds
|
|
371
|
+
confirm_delay = self._stt._end_of_turn_confirm_delay_seconds
|
|
372
|
+
while True:
|
|
373
|
+
await asyncio.sleep(_WATCHDOG_POLL_SECONDS)
|
|
374
|
+
now = time.monotonic()
|
|
375
|
+
if (
|
|
376
|
+
confirm_delay is not None
|
|
377
|
+
and self._pending_eot_at is not None
|
|
378
|
+
and now - self._pending_eot_at >= confirm_delay
|
|
379
|
+
):
|
|
380
|
+
self._flush_eos()
|
|
381
|
+
continue
|
|
382
|
+
if (
|
|
383
|
+
inactivity_timeout is not None
|
|
384
|
+
and self._current_text()
|
|
385
|
+
and now - self._last_progress_at >= inactivity_timeout
|
|
386
|
+
):
|
|
387
|
+
self._flush_eos()
|
|
388
|
+
|
|
389
|
+
async def _send_task(self, ws: aiohttp.ClientWebSocketResponse) -> None:
|
|
390
|
+
try:
|
|
391
|
+
async for data in self._input_ch:
|
|
392
|
+
if isinstance(data, rtc.AudioFrame):
|
|
393
|
+
audio_bytes = data.data.tobytes()
|
|
394
|
+
if audio_bytes:
|
|
395
|
+
self._audio_position += data.samples_per_channel / data.sample_rate
|
|
396
|
+
await ws.send_bytes(audio_bytes)
|
|
397
|
+
self._input_done = True
|
|
398
|
+
await ws.send_str(json.dumps({"type": "eof"}))
|
|
399
|
+
except Exception as e:
|
|
400
|
+
raise self._stream_failure(f"nabrah STT send failed ({type(e).__name__})") from None
|
|
401
|
+
|
|
402
|
+
async def _recv_task(self, ws: aiohttp.ClientWebSocketResponse) -> None:
|
|
403
|
+
while True:
|
|
404
|
+
try:
|
|
405
|
+
msg = await ws.receive()
|
|
406
|
+
except Exception as e:
|
|
407
|
+
raise self._stream_failure(
|
|
408
|
+
f"nabrah STT receive failed ({type(e).__name__})"
|
|
409
|
+
) from None
|
|
410
|
+
if msg.type in (
|
|
411
|
+
aiohttp.WSMsgType.CLOSED,
|
|
412
|
+
aiohttp.WSMsgType.CLOSE,
|
|
413
|
+
aiohttp.WSMsgType.CLOSING,
|
|
414
|
+
):
|
|
415
|
+
if not self._input_done:
|
|
416
|
+
raise self._stream_failure("nabrah STT closed unexpectedly")
|
|
417
|
+
return
|
|
418
|
+
if msg.type == aiohttp.WSMsgType.ERROR:
|
|
419
|
+
raise self._stream_failure("nabrah STT WebSocket failed")
|
|
420
|
+
if msg.type != aiohttp.WSMsgType.TEXT:
|
|
421
|
+
continue
|
|
422
|
+
try:
|
|
423
|
+
self._process_message(json.loads(msg.data))
|
|
424
|
+
except APIError:
|
|
425
|
+
raise
|
|
426
|
+
except Exception:
|
|
427
|
+
# Never attach an exception or payload here: malformed messages can
|
|
428
|
+
# contain customer transcripts. Later cumulative results can recover.
|
|
429
|
+
logger.warning("nabrah STT returned malformed data")
|
|
430
|
+
|
|
431
|
+
def _emit(self, event: stt.SpeechEvent) -> None:
|
|
432
|
+
self._event_ch.send_nowait(event)
|
|
433
|
+
|
|
434
|
+
def _audio_clock(self) -> float:
|
|
435
|
+
"""Audio-stream position for this connection, in seconds.
|
|
436
|
+
|
|
437
|
+
The backend's audio_processed is what the recognizer actually consumed,
|
|
438
|
+
so it cannot run ahead of realtime the way a send-side count does when
|
|
439
|
+
input arrives in bursts. Falls back to audio pushed if absent.
|
|
440
|
+
"""
|
|
441
|
+
if self._latest_audio_processed is not None:
|
|
442
|
+
return self._latest_audio_processed
|
|
443
|
+
return self._audio_position
|
|
444
|
+
|
|
445
|
+
def _turn_word_list(self) -> tuple[TimedString, ...]:
|
|
446
|
+
"""Every word in the turn so far: committed utterances plus the open one."""
|
|
447
|
+
return self._turn_words + self._utt_words
|
|
448
|
+
|
|
449
|
+
def _current_text(self) -> str:
|
|
450
|
+
return _append_text(self._turn_text, self._utt_clean)
|
|
451
|
+
|
|
452
|
+
def _flush_eos(self) -> None:
|
|
453
|
+
text = self._current_text()
|
|
454
|
+
end_time = self._segment_end_time or (self._audio_clock() + self.start_time_offset)
|
|
455
|
+
start_time = min(self._segment_start_time, end_time)
|
|
456
|
+
|
|
457
|
+
if text:
|
|
458
|
+
self._emit(
|
|
459
|
+
stt.SpeechEvent(
|
|
460
|
+
type=stt.SpeechEventType.FINAL_TRANSCRIPT,
|
|
461
|
+
request_id=self._request_id,
|
|
462
|
+
alternatives=[
|
|
463
|
+
stt.SpeechData(
|
|
464
|
+
language=self._language,
|
|
465
|
+
text=text,
|
|
466
|
+
start_time=start_time,
|
|
467
|
+
end_time=end_time,
|
|
468
|
+
words=list(self._turn_word_list()) or None,
|
|
469
|
+
)
|
|
470
|
+
],
|
|
471
|
+
)
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
audio_clock = self._audio_clock()
|
|
475
|
+
usage_duration = audio_clock - self._reported_audio_position
|
|
476
|
+
if usage_duration > 0:
|
|
477
|
+
self._emit(
|
|
478
|
+
stt.SpeechEvent(
|
|
479
|
+
type=stt.SpeechEventType.RECOGNITION_USAGE,
|
|
480
|
+
recognition_usage=stt.RecognitionUsage(
|
|
481
|
+
audio_duration=usage_duration,
|
|
482
|
+
),
|
|
483
|
+
)
|
|
484
|
+
)
|
|
485
|
+
self._reported_audio_position = audio_clock
|
|
486
|
+
|
|
487
|
+
if self._is_speaking:
|
|
488
|
+
self._emit(
|
|
489
|
+
stt.SpeechEvent(
|
|
490
|
+
type=stt.SpeechEventType.END_OF_SPEECH,
|
|
491
|
+
alternatives=[
|
|
492
|
+
stt.SpeechData(
|
|
493
|
+
language=self._language,
|
|
494
|
+
text="",
|
|
495
|
+
start_time=start_time,
|
|
496
|
+
end_time=end_time,
|
|
497
|
+
)
|
|
498
|
+
],
|
|
499
|
+
)
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
self._utt_flushed_clean = _append_text(self._utt_flushed_clean, self._utt_clean)
|
|
503
|
+
self._utt_flushed_chars = len(self._utt_flushed_clean)
|
|
504
|
+
# index into the backend's raw `words` list, so count raw entries consumed,
|
|
505
|
+
# not the filtered survivors, or blank entries shift the cursor backwards
|
|
506
|
+
# and already-emitted words get replayed.
|
|
507
|
+
self._utt_flushed_words = self._utt_raw_seen
|
|
508
|
+
|
|
509
|
+
self._turn_text = ""
|
|
510
|
+
self._utt_clean = ""
|
|
511
|
+
self._turn_words = ()
|
|
512
|
+
self._utt_words = ()
|
|
513
|
+
self._is_speaking = False
|
|
514
|
+
self._pending_eot_at = None
|
|
515
|
+
self._segment_start_time = 0.0
|
|
516
|
+
self._segment_end_time = 0.0
|
|
517
|
+
self._request_id = ""
|
|
518
|
+
|
|
519
|
+
def _emit_preflight(self) -> None:
|
|
520
|
+
text = self._current_text()
|
|
521
|
+
if not text:
|
|
522
|
+
return
|
|
523
|
+
|
|
524
|
+
self._emit(
|
|
525
|
+
stt.SpeechEvent(
|
|
526
|
+
type=stt.SpeechEventType.PREFLIGHT_TRANSCRIPT,
|
|
527
|
+
request_id=self._request_id,
|
|
528
|
+
alternatives=[
|
|
529
|
+
stt.SpeechData(
|
|
530
|
+
language=self._language,
|
|
531
|
+
text=text,
|
|
532
|
+
start_time=self._segment_start_time,
|
|
533
|
+
end_time=self._segment_end_time,
|
|
534
|
+
)
|
|
535
|
+
],
|
|
536
|
+
)
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
def _process_message(self, data: dict[str, Any]) -> None:
|
|
540
|
+
msg_type = data.get("type")
|
|
541
|
+
|
|
542
|
+
if msg_type == "error":
|
|
543
|
+
raise APIStatusError(
|
|
544
|
+
message="nabrah STT reported an error",
|
|
545
|
+
status_code=-1,
|
|
546
|
+
request_id=None,
|
|
547
|
+
body=None,
|
|
548
|
+
retryable=False,
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
if msg_type != "transcript":
|
|
552
|
+
return
|
|
553
|
+
|
|
554
|
+
text = data.get("text", "")
|
|
555
|
+
is_final = data.get("is_final", False)
|
|
556
|
+
if not isinstance(is_final, bool):
|
|
557
|
+
raise ValueError("nabrah is_final must be a boolean")
|
|
558
|
+
utt_final = is_final
|
|
559
|
+
|
|
560
|
+
previous_audio_position = self._last_message_position
|
|
561
|
+
reported = data.get("audio_processed")
|
|
562
|
+
if reported is not None:
|
|
563
|
+
reported_seconds = _finite_number(reported, field="audio_processed")
|
|
564
|
+
if (
|
|
565
|
+
self._latest_audio_processed is None
|
|
566
|
+
or reported_seconds > self._latest_audio_processed
|
|
567
|
+
):
|
|
568
|
+
self._latest_audio_processed = reported_seconds
|
|
569
|
+
self._last_message_position = self._audio_clock()
|
|
570
|
+
|
|
571
|
+
if not text:
|
|
572
|
+
return
|
|
573
|
+
|
|
574
|
+
clean_now, _ = _strip_and_detect_eot(text)
|
|
575
|
+
clean_now = _normalize_whitespace(clean_now)
|
|
576
|
+
if self._utt_closed:
|
|
577
|
+
self._turn_text = _append_text(self._turn_text, self._utt_clean)
|
|
578
|
+
self._utt_clean = ""
|
|
579
|
+
self._utt_raw = ""
|
|
580
|
+
self._utt_flushed_clean = ""
|
|
581
|
+
self._utt_flushed_chars = 0
|
|
582
|
+
self._turn_words = self._turn_words + self._utt_words
|
|
583
|
+
self._utt_words = ()
|
|
584
|
+
self._utt_flushed_words = 0
|
|
585
|
+
self._utt_raw_seen = 0
|
|
586
|
+
self._utt_closed = False
|
|
587
|
+
|
|
588
|
+
raw_words = data.get("words") or []
|
|
589
|
+
if raw_words:
|
|
590
|
+
self._utt_words = tuple(
|
|
591
|
+
_timed_word(w, self.start_time_offset)
|
|
592
|
+
for w in raw_words[self._utt_flushed_words :]
|
|
593
|
+
if _word_text(w)
|
|
594
|
+
)
|
|
595
|
+
self._utt_raw_seen = len(raw_words)
|
|
596
|
+
|
|
597
|
+
new_text = text[len(self._utt_raw) :] if text.startswith(self._utt_raw) else text
|
|
598
|
+
_, is_eot = _strip_and_detect_eot(new_text)
|
|
599
|
+
self._utt_clean = clean_now[self._utt_flushed_chars :]
|
|
600
|
+
self._utt_raw = text
|
|
601
|
+
if utt_final:
|
|
602
|
+
self._utt_closed = True
|
|
603
|
+
|
|
604
|
+
if not new_text:
|
|
605
|
+
return
|
|
606
|
+
|
|
607
|
+
has_real_progress = bool(new_text.replace(EOT_TOKEN, "").strip())
|
|
608
|
+
|
|
609
|
+
if has_real_progress:
|
|
610
|
+
self._last_progress_at = time.monotonic()
|
|
611
|
+
self._pending_eot_at = None
|
|
612
|
+
|
|
613
|
+
turn_words = self._turn_word_list()
|
|
614
|
+
|
|
615
|
+
word_start = turn_words[0].start_time if turn_words else NOT_GIVEN
|
|
616
|
+
word_end = turn_words[-1].end_time if turn_words else NOT_GIVEN
|
|
617
|
+
|
|
618
|
+
if not self._is_speaking:
|
|
619
|
+
self._is_speaking = True
|
|
620
|
+
self._segment_start_time = (
|
|
621
|
+
word_start
|
|
622
|
+
if is_given(word_start)
|
|
623
|
+
else min(previous_audio_position, self._audio_clock()) + self.start_time_offset
|
|
624
|
+
)
|
|
625
|
+
self._emit(
|
|
626
|
+
stt.SpeechEvent(
|
|
627
|
+
type=stt.SpeechEventType.START_OF_SPEECH,
|
|
628
|
+
speech_start_time=(
|
|
629
|
+
self.start_time - self.start_time_offset + self._segment_start_time
|
|
630
|
+
),
|
|
631
|
+
)
|
|
632
|
+
)
|
|
633
|
+
self._segment_end_time = (
|
|
634
|
+
word_end if is_given(word_end) else self._audio_clock() + self.start_time_offset
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
if not self._request_id:
|
|
638
|
+
self._request_id = utils.shortuuid("nabrah-")
|
|
639
|
+
|
|
640
|
+
current = self._current_text()
|
|
641
|
+
if current:
|
|
642
|
+
self._emit(
|
|
643
|
+
stt.SpeechEvent(
|
|
644
|
+
type=stt.SpeechEventType.INTERIM_TRANSCRIPT,
|
|
645
|
+
alternatives=[stt.SpeechData(language=self._language, text=current)],
|
|
646
|
+
)
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
if is_eot:
|
|
650
|
+
if self._stt._end_of_turn_confirm_delay_seconds is None:
|
|
651
|
+
self._flush_eos()
|
|
652
|
+
elif self._pending_eot_at is None:
|
|
653
|
+
self._pending_eot_at = time.monotonic()
|
|
654
|
+
self._emit_preflight()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Copyright 2026 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.6"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "livekit-plugins-nabrah"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "LiveKit Agents plugin for Nabrah Arabic speech-to-text"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "LiveKit", email = "hello@livekit.io" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"webrtc",
|
|
15
|
+
"realtime",
|
|
16
|
+
"audio",
|
|
17
|
+
"livekit",
|
|
18
|
+
"livekit-agents",
|
|
19
|
+
"nabrah",
|
|
20
|
+
"arabic",
|
|
21
|
+
"stt",
|
|
22
|
+
"speech-to-text",
|
|
23
|
+
"streaming",
|
|
24
|
+
"websocket",
|
|
25
|
+
]
|
|
26
|
+
classifiers = [
|
|
27
|
+
"Intended Audience :: Developers",
|
|
28
|
+
"License :: OSI Approved :: Apache Software License",
|
|
29
|
+
"Topic :: Multimedia :: Sound/Audio",
|
|
30
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
31
|
+
"Programming Language :: Python :: 3",
|
|
32
|
+
"Programming Language :: Python :: 3.10",
|
|
33
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
34
|
+
]
|
|
35
|
+
dependencies = ["livekit-agents>=1.8.6", "aiohttp"]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Documentation = "https://docs.livekit.io"
|
|
39
|
+
Website = "https://nabrah.ai"
|
|
40
|
+
Source = "https://github.com/livekit/agents"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.version]
|
|
43
|
+
path = "livekit/plugins/nabrah/version.py"
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
packages = ["livekit"]
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.sdist]
|
|
49
|
+
include = ["/livekit"]
|
|
50
|
+
|
|
51
|
+
[tool.uv]
|
|
52
|
+
exclude-newer = "7 days"
|
|
53
|
+
exclude-newer-package = { livekit-agents = "0 days" }
|