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.
@@ -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")
@@ -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" }