livekit-plugins-meta 1.8.1__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,62 @@
1
+ Metadata-Version: 2.5
2
+ Name: livekit-plugins-meta
3
+ Version: 1.8.1
4
+ Summary: Meta Muse Voice Transcribe plugin for LiveKit Agents
5
+ Project-URL: Documentation, https://docs.livekit.io
6
+ Project-URL: Website, https://livekit.io/
7
+ Project-URL: Source, https://github.com/livekit/agents
8
+ Author-email: LiveKit <hello@livekit.io>
9
+ License-Expression: Apache-2.0
10
+ Keywords: audio,livekit,meta,muse,realtime,speech-to-text,stt,webrtc
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: Apache Software License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Topic :: Multimedia :: Sound/Audio
17
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
18
+ Requires-Python: >=3.10.0
19
+ Requires-Dist: livekit-agents>=1.8.1
20
+ Description-Content-Type: text/markdown
21
+
22
+ # Meta plugin for LiveKit Agents
23
+
24
+ Support for Meta [Muse Voice Transcribe](https://dev.meta.ai/docs/speech-to-text) using its [realtime streaming speech-to-text interface](https://dev.meta.ai/docs/api-reference/voice/realtime).
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ pip install livekit-plugins-meta
30
+ ```
31
+
32
+ ## Pre-requisites
33
+
34
+ Set a Meta Model API key in your environment using either variable:
35
+
36
+ ```bash
37
+ MODEL_API_KEY=<your_model_api_key>
38
+ # Or: META_API_KEY=<your_model_api_key>
39
+ ```
40
+
41
+ You can also pass the key directly with `api_key=`. The resolution order is `api_key`, `MODEL_API_KEY`, then `META_API_KEY`.
42
+
43
+ ## Usage
44
+
45
+ ```python
46
+ from livekit.agents import AgentSession
47
+ from livekit.plugins import meta
48
+
49
+ session = AgentSession(
50
+ stt=meta.STT(
51
+ keywords=["LiveKit", "Muse"],
52
+ language_bias=["English"],
53
+ ),
54
+ # ... llm, tts, etc.
55
+ )
56
+ ```
57
+
58
+ The plugin supports streaming recognition with server-side endpointing, cumulative interim transcripts, and mono PCM16 audio at 24 kHz. Connected streams must keep sending real-time PCM, including silence. `keywords` and `language_bias` are static hints applied during the initial handshake and cannot be changed on an active stream. Omit `language_bias` for automatic language detection.
59
+
60
+ Supported language names are: Arabic, Bengali, Dutch, English, French, German, Hebrew, Hindi, Indonesian, Italian, Japanese, Kannada, Korean, Malay, Mandarin Chinese, Marathi, Polish, Portuguese, Spanish, Tagalog, Tamil, Telugu, Thai, Turkish, and Vietnamese. The per-stream `language=` argument also accepts corresponding language codes and locales, such as `en-US`, `pt-BR`, and `zh-CN`, and maps them to the documented names.
61
+
62
+ Muse realtime sessions have a maximum duration of 60 minutes. This plugin reports the provider close and does not rotate an active LiveKit speech stream automatically; start a new stream to continue. Batch recognition, diarization, detected-language metadata, and active-stream keyterm updates are not supported.
@@ -0,0 +1,41 @@
1
+ # Meta plugin for LiveKit Agents
2
+
3
+ Support for Meta [Muse Voice Transcribe](https://dev.meta.ai/docs/speech-to-text) using its [realtime streaming speech-to-text interface](https://dev.meta.ai/docs/api-reference/voice/realtime).
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install livekit-plugins-meta
9
+ ```
10
+
11
+ ## Pre-requisites
12
+
13
+ Set a Meta Model API key in your environment using either variable:
14
+
15
+ ```bash
16
+ MODEL_API_KEY=<your_model_api_key>
17
+ # Or: META_API_KEY=<your_model_api_key>
18
+ ```
19
+
20
+ You can also pass the key directly with `api_key=`. The resolution order is `api_key`, `MODEL_API_KEY`, then `META_API_KEY`.
21
+
22
+ ## Usage
23
+
24
+ ```python
25
+ from livekit.agents import AgentSession
26
+ from livekit.plugins import meta
27
+
28
+ session = AgentSession(
29
+ stt=meta.STT(
30
+ keywords=["LiveKit", "Muse"],
31
+ language_bias=["English"],
32
+ ),
33
+ # ... llm, tts, etc.
34
+ )
35
+ ```
36
+
37
+ The plugin supports streaming recognition with server-side endpointing, cumulative interim transcripts, and mono PCM16 audio at 24 kHz. Connected streams must keep sending real-time PCM, including silence. `keywords` and `language_bias` are static hints applied during the initial handshake and cannot be changed on an active stream. Omit `language_bias` for automatic language detection.
38
+
39
+ Supported language names are: Arabic, Bengali, Dutch, English, French, German, Hebrew, Hindi, Indonesian, Italian, Japanese, Kannada, Korean, Malay, Mandarin Chinese, Marathi, Polish, Portuguese, Spanish, Tagalog, Tamil, Telugu, Thai, Turkish, and Vietnamese. The per-stream `language=` argument also accepts corresponding language codes and locales, such as `en-US`, `pt-BR`, and `zh-CN`, and maps them to the documented names.
40
+
41
+ Muse realtime sessions have a maximum duration of 60 minutes. This plugin reports the provider close and does not rotate an active LiveKit speech stream automatically; start a new stream to continue. Batch recognition, diarization, detected-language metadata, and active-stream keyterm updates are not supported.
@@ -0,0 +1,40 @@
1
+ # Copyright 2023 LiveKit, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Meta Muse Voice Transcribe plugin for LiveKit Agents."""
16
+
17
+ from .stt import STT, SpeechStream
18
+ from .version import __version__
19
+
20
+ __all__ = ["STT", "SpeechStream", "__version__"]
21
+
22
+ from livekit.agents import Plugin
23
+
24
+ from .log import logger
25
+
26
+
27
+ class MetaPlugin(Plugin):
28
+ def __init__(self) -> None:
29
+ super().__init__(__name__, __version__, __package__, logger)
30
+
31
+
32
+ Plugin.register_plugin(MetaPlugin())
33
+
34
+ # Cleanup docs of unexported modules.
35
+ _module = dir()
36
+ NOT_IN_ALL = [name for name in _module if name not in __all__]
37
+
38
+ __pdoc__ = {}
39
+ for name in NOT_IN_ALL:
40
+ __pdoc__[name] = False
@@ -0,0 +1,17 @@
1
+ # Copyright 2023 LiveKit, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import logging
16
+
17
+ logger = logging.getLogger("livekit.plugins.meta")
@@ -0,0 +1,801 @@
1
+ # Copyright 2023 LiveKit, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import json
19
+ import math
20
+ import os
21
+ import time
22
+ import weakref
23
+ from collections import OrderedDict, deque
24
+ from dataclasses import dataclass
25
+ from typing import Any
26
+ from urllib.parse import urlparse
27
+
28
+ import aiohttp
29
+
30
+ from livekit.agents import (
31
+ DEFAULT_API_CONNECT_OPTIONS,
32
+ APIConnectionError,
33
+ APIConnectOptions,
34
+ APIError,
35
+ APIStatusError,
36
+ APITimeoutError,
37
+ LanguageCode,
38
+ stt,
39
+ utils,
40
+ )
41
+ from livekit.agents.types import NOT_GIVEN, NotGivenOr
42
+ from livekit.agents.utils import AudioBuffer, is_given
43
+
44
+ DEFAULT_URL = "wss://api.meta.ai/v1/asr/realtime"
45
+ DEFAULT_MODEL = "muse-voice-transcribe-1.0"
46
+ _SAMPLE_RATE = 24_000
47
+ _CHANNELS = 1
48
+ _SAMPLE_WIDTH_BYTES = 2
49
+ _CHUNK_DURATION = 0.08
50
+ _CHUNK_BYTES = int(_SAMPLE_RATE * _CHANNELS * _SAMPLE_WIDTH_BYTES * _CHUNK_DURATION)
51
+ _MAX_MESSAGE_BYTES = 1024 * 1024
52
+ _MAX_COMPLETED_TURNS = 128
53
+ _SUPPORTED_LANGUAGES = (
54
+ "Arabic",
55
+ "Bengali",
56
+ "Dutch",
57
+ "English",
58
+ "French",
59
+ "German",
60
+ "Hebrew",
61
+ "Hindi",
62
+ "Indonesian",
63
+ "Italian",
64
+ "Japanese",
65
+ "Kannada",
66
+ "Korean",
67
+ "Malay",
68
+ "Mandarin Chinese",
69
+ "Marathi",
70
+ "Polish",
71
+ "Portuguese",
72
+ "Spanish",
73
+ "Tagalog",
74
+ "Tamil",
75
+ "Telugu",
76
+ "Thai",
77
+ "Turkish",
78
+ "Vietnamese",
79
+ )
80
+ _LANGUAGE_NAMES = {language.casefold(): language for language in _SUPPORTED_LANGUAGES}
81
+ _LANGUAGE_CODES = {
82
+ "ar": "Arabic",
83
+ "bn": "Bengali",
84
+ "de": "German",
85
+ "en": "English",
86
+ "es": "Spanish",
87
+ "fil": "Tagalog",
88
+ "fr": "French",
89
+ "he": "Hebrew",
90
+ "hi": "Hindi",
91
+ "id": "Indonesian",
92
+ "it": "Italian",
93
+ "iw": "Hebrew",
94
+ "ja": "Japanese",
95
+ "kn": "Kannada",
96
+ "ko": "Korean",
97
+ "ms": "Malay",
98
+ "mr": "Marathi",
99
+ "nl": "Dutch",
100
+ "pl": "Polish",
101
+ "pt": "Portuguese",
102
+ "ta": "Tamil",
103
+ "te": "Telugu",
104
+ "th": "Thai",
105
+ "tl": "Tagalog",
106
+ "tr": "Turkish",
107
+ "vi": "Vietnamese",
108
+ "zh": "Mandarin Chinese",
109
+ }
110
+ _RETRYABLE_CLOSE_CODES = frozenset((1011, 1013))
111
+ _NON_RETRYABLE_CLOSE_CODES = frozenset((1008,))
112
+
113
+
114
+ def _normalize_hints(values: list[str] | None, *, name: str) -> list[str]:
115
+ normalized: list[str] = []
116
+ for value in values or ():
117
+ hint = value.strip()
118
+ if not hint:
119
+ raise ValueError(f"{name} entries must be non-empty")
120
+ if hint not in normalized:
121
+ normalized.append(hint)
122
+ return normalized
123
+
124
+
125
+ def _normalize_access_token(api_key: str) -> str:
126
+ parts = api_key.split(None, 1)
127
+ if parts and parts[0].casefold() == "bearer":
128
+ if len(parts) != 2 or not parts[1].strip():
129
+ raise ValueError("Meta Model API key must include a token after Bearer")
130
+ return f"Bearer {parts[1].strip()}"
131
+ return f"Bearer {api_key}"
132
+
133
+
134
+ def _normalize_language_bias(values: list[str] | None) -> list[str]:
135
+ normalized: list[str] = []
136
+ for value in values or ():
137
+ documented_name = _LANGUAGE_NAMES.get(value.strip().casefold())
138
+ if documented_name is None:
139
+ supported = ", ".join(_SUPPORTED_LANGUAGES)
140
+ raise ValueError(f"unsupported language_bias entry {value!r}; supported: {supported}")
141
+ if documented_name not in normalized:
142
+ normalized.append(documented_name)
143
+ return normalized
144
+
145
+
146
+ def _normalize_language_hint(language: str) -> str:
147
+ value = language.strip()
148
+ if not value:
149
+ raise ValueError("language must be non-empty")
150
+
151
+ documented_name = _LANGUAGE_NAMES.get(value.casefold())
152
+ if documented_name is not None:
153
+ return documented_name
154
+
155
+ primary = value.replace("_", "-").split("-", 1)[0].casefold()
156
+ mapped_name = _LANGUAGE_CODES.get(primary)
157
+ if mapped_name is None:
158
+ supported = ", ".join(_SUPPORTED_LANGUAGES)
159
+ raise ValueError(f"unsupported Muse Voice language {language!r}; supported: {supported}")
160
+ return mapped_name
161
+
162
+
163
+ def _protocol_error(detail: str) -> APIConnectionError:
164
+ return APIConnectionError(
165
+ f"Meta Muse realtime ASR protocol error: {detail}",
166
+ retryable=False,
167
+ )
168
+
169
+
170
+ def _server_error(*, phase: str) -> APIStatusError:
171
+ return APIStatusError(
172
+ f"Meta Muse realtime ASR {phase} error",
173
+ status_code=400,
174
+ request_id=None,
175
+ body=None,
176
+ retryable=False,
177
+ )
178
+
179
+
180
+ def _close_error(
181
+ close_code: int | None,
182
+ *,
183
+ phase: str,
184
+ retryable_on_normal_close: bool = False,
185
+ ) -> APIStatusError:
186
+ code = close_code or -1
187
+ if code == 1000:
188
+ retryable = retryable_on_normal_close
189
+ elif code in _NON_RETRYABLE_CLOSE_CODES:
190
+ retryable = False
191
+ elif code in _RETRYABLE_CLOSE_CODES:
192
+ retryable = True
193
+ else:
194
+ retryable = True
195
+ return APIStatusError(
196
+ f"Meta Muse realtime ASR closed during {phase}",
197
+ status_code=code,
198
+ body=None,
199
+ retryable=retryable,
200
+ )
201
+
202
+
203
+ def _parse_ws_message(
204
+ raw: aiohttp.WSMessage,
205
+ *,
206
+ phase: str,
207
+ close_code: int | None = None,
208
+ retryable_on_normal_close: bool = False,
209
+ ) -> dict[str, Any]:
210
+ if raw.type != aiohttp.WSMsgType.TEXT or not isinstance(raw.data, str):
211
+ if raw.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED):
212
+ raw_close_code = raw.data if isinstance(raw.data, int) else close_code
213
+ raise _close_error(
214
+ raw_close_code,
215
+ phase=phase,
216
+ retryable_on_normal_close=retryable_on_normal_close,
217
+ )
218
+ raise _protocol_error(f"unexpected message type during {phase}")
219
+ try:
220
+ message = json.loads(raw.data)
221
+ except (json.JSONDecodeError, TypeError):
222
+ raise _protocol_error(f"invalid JSON during {phase}") from None
223
+ if not isinstance(message, dict):
224
+ raise _protocol_error(f"non-object message during {phase}")
225
+ return message
226
+
227
+
228
+ async def _close_quietly(ws: aiohttp.ClientWebSocketResponse) -> None:
229
+ try:
230
+ await ws.close()
231
+ except Exception:
232
+ pass
233
+
234
+
235
+ def _normalize_turn_id(value: object, *, event: str) -> str:
236
+ if isinstance(value, bool) or not isinstance(value, (str, int)):
237
+ raise _protocol_error(f"{event} event has an invalid turnId")
238
+ turn_id = str(value).strip()
239
+ if not turn_id:
240
+ raise _protocol_error(f"{event} event has an invalid turnId")
241
+ return turn_id
242
+
243
+
244
+ @dataclass(slots=True)
245
+ class _TurnState:
246
+ provider_started: bool = False
247
+ emitted_start: bool = False
248
+ latest_interim: str | None = None
249
+ emitted_interim: str | None = None
250
+ final_text: str | None = None
251
+ final_emitted: bool = False
252
+ ended: bool = False
253
+ usage_seconds: float = 0.0
254
+ usage_captured: bool = False
255
+
256
+
257
+ class STT(stt.STT[Any]):
258
+ """Streaming speech recognition with Meta Muse Voice Transcribe."""
259
+
260
+ def __init__(
261
+ self,
262
+ *,
263
+ api_key: str | None = None,
264
+ model: str = DEFAULT_MODEL,
265
+ url: str = DEFAULT_URL,
266
+ keywords: list[str] | None = None,
267
+ language_bias: list[str] | None = None,
268
+ http_session: aiohttp.ClientSession | None = None,
269
+ ) -> None:
270
+ """Create a Meta Muse streaming STT provider.
271
+
272
+ Args:
273
+ api_key: Meta Model API key. Falls back to ``MODEL_API_KEY``, then
274
+ ``META_API_KEY``.
275
+ model: Muse Voice Transcribe model identifier.
276
+ url: Realtime Muse ASR WebSocket endpoint. Must use ``wss://``.
277
+ keywords: Static recognition keywords sent when each stream starts.
278
+ language_bias: Static supported language names sent when each stream starts.
279
+ http_session: Optional aiohttp session. By default, the LiveKit HTTP
280
+ context session is used.
281
+ """
282
+ if api_key is not None:
283
+ resolved_key = api_key.strip()
284
+ else:
285
+ resolved_key = (
286
+ os.getenv("MODEL_API_KEY", "").strip() or os.getenv("META_API_KEY", "").strip()
287
+ )
288
+ if not resolved_key:
289
+ raise ValueError(
290
+ "Meta Model API key is required. Pass api_key or set MODEL_API_KEY or META_API_KEY"
291
+ )
292
+ if not model.strip():
293
+ raise ValueError("model must be non-empty")
294
+
295
+ parsed_url = urlparse(url)
296
+ if (
297
+ parsed_url.scheme != "wss"
298
+ or not parsed_url.hostname
299
+ or parsed_url.username is not None
300
+ or parsed_url.password is not None
301
+ or parsed_url.fragment
302
+ ):
303
+ raise ValueError("url must be an absolute wss:// URL without credentials or a fragment")
304
+
305
+ super().__init__(
306
+ capabilities=stt.STTCapabilities(
307
+ streaming=True,
308
+ interim_results=True,
309
+ diarization=False,
310
+ aligned_transcript=False,
311
+ offline_recognize=False,
312
+ keyterms=False,
313
+ )
314
+ )
315
+ self._api_key = _normalize_access_token(resolved_key)
316
+ self._model = model.strip()
317
+ self._url = url
318
+ self._keywords = _normalize_hints(keywords, name="keywords")
319
+ self._language_bias = _normalize_language_bias(language_bias)
320
+ self._http_session = http_session
321
+ self._streams: weakref.WeakSet[SpeechStream] = weakref.WeakSet()
322
+ self._closed = False
323
+
324
+ @property
325
+ def model(self) -> str:
326
+ return self._model
327
+
328
+ @property
329
+ def provider(self) -> str:
330
+ return "Meta"
331
+
332
+ def _ensure_session(self) -> aiohttp.ClientSession:
333
+ if self._http_session is None:
334
+ self._http_session = utils.http_context.http_session()
335
+ return self._http_session
336
+
337
+ async def _recognize_impl(
338
+ self,
339
+ buffer: AudioBuffer,
340
+ *,
341
+ language: NotGivenOr[str] = NOT_GIVEN,
342
+ conn_options: APIConnectOptions,
343
+ ) -> stt.SpeechEvent:
344
+ raise APIError(
345
+ "Meta Muse Voice Transcribe supports streaming recognition only",
346
+ retryable=False,
347
+ )
348
+
349
+ def stream(
350
+ self,
351
+ *,
352
+ language: NotGivenOr[str] = NOT_GIVEN,
353
+ conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
354
+ ) -> SpeechStream:
355
+ if self._closed:
356
+ raise RuntimeError("Meta STT is closed")
357
+
358
+ language_bias = list(self._language_bias)
359
+ if is_given(language):
360
+ language_hint = _normalize_language_hint(str(language))
361
+ if language_hint not in language_bias:
362
+ language_bias.append(language_hint)
363
+
364
+ stream = SpeechStream(
365
+ stt=self,
366
+ conn_options=conn_options,
367
+ api_key=self._api_key,
368
+ model=self._model,
369
+ url=self._url,
370
+ keywords=list(self._keywords),
371
+ language_bias=language_bias,
372
+ http_session=self._ensure_session(),
373
+ )
374
+ self._streams.add(stream)
375
+ return stream
376
+
377
+ async def aclose(self) -> None:
378
+ self._closed = True
379
+ streams = tuple(self._streams)
380
+ if streams:
381
+ await asyncio.gather(*(stream.aclose() for stream in streams), return_exceptions=True)
382
+
383
+
384
+ class SpeechStream(stt.RecognizeStream):
385
+ def __init__(
386
+ self,
387
+ *,
388
+ stt: STT,
389
+ conn_options: APIConnectOptions,
390
+ api_key: str,
391
+ model: str,
392
+ url: str,
393
+ keywords: list[str],
394
+ language_bias: list[str],
395
+ http_session: aiohttp.ClientSession,
396
+ ) -> None:
397
+ super().__init__(stt=stt, conn_options=conn_options, sample_rate=_SAMPLE_RATE)
398
+ self._api_key = api_key
399
+ self._model = model
400
+ self._url = url
401
+ self._keywords = keywords
402
+ self._language_bias = language_bias
403
+ self._session = http_session
404
+ self._session_id = ""
405
+ self._turns: OrderedDict[str, _TurnState] = OrderedDict()
406
+ self._provider_active_turn_id: str | None = None
407
+ self._completed_turn_ids: set[str] = set()
408
+ self._completed_turn_order: deque[str] = deque()
409
+ self._audio_consumed = False
410
+ self._end_stream_sent = False
411
+ self._last_audio_processed_ms = 0.0
412
+ self._pending_usage_seconds = 0.0
413
+
414
+ async def _run(self) -> None:
415
+ self._end_stream_sent = False
416
+ self._last_audio_processed_ms = 0.0
417
+ ws: aiohttp.ClientWebSocketResponse | None = None
418
+ tasks: list[asyncio.Task[None]] = []
419
+ try:
420
+ ws = await self._connect_ws()
421
+ sender = asyncio.create_task(self._send_audio(ws), name="meta-stt-send")
422
+ receiver = asyncio.create_task(self._receive_events(ws), name="meta-stt-receive")
423
+ tasks = [sender, receiver]
424
+ await self._drive_tasks(sender, receiver)
425
+ except asyncio.CancelledError:
426
+ raise
427
+ except APIError as exc:
428
+ if self._audio_consumed and exc.retryable:
429
+ raise APIConnectionError(
430
+ "Meta Muse realtime ASR failed after audio was consumed",
431
+ retryable=False,
432
+ ) from None
433
+ raise
434
+ except Exception as exc:
435
+ phase = "audio streaming" if self._audio_consumed else "connection"
436
+ raise APIConnectionError(
437
+ f"Meta Muse realtime ASR {phase} failed ({type(exc).__name__})",
438
+ retryable=not self._audio_consumed,
439
+ ) from None
440
+ finally:
441
+ if tasks:
442
+ await utils.aio.gracefully_cancel(*tasks)
443
+ self._flush_usage()
444
+ if ws is not None:
445
+ await _close_quietly(ws)
446
+
447
+ async def _connect_ws(self) -> aiohttp.ClientWebSocketResponse:
448
+ started_at = time.perf_counter()
449
+ try:
450
+ ws = await asyncio.wait_for(
451
+ self._session.ws_connect(
452
+ self._url,
453
+ max_msg_size=_MAX_MESSAGE_BYTES,
454
+ ),
455
+ timeout=self._conn_options.timeout,
456
+ )
457
+ except asyncio.TimeoutError:
458
+ raise APITimeoutError("Meta Muse realtime ASR connection timed out") from None
459
+ except aiohttp.ClientResponseError as exc:
460
+ raise APIStatusError(
461
+ "Meta Muse realtime ASR connection was rejected",
462
+ status_code=exc.status,
463
+ body=None,
464
+ ) from None
465
+ except Exception as exc:
466
+ raise APIConnectionError(
467
+ f"Meta Muse realtime ASR connection failed ({type(exc).__name__})"
468
+ ) from None
469
+
470
+ try:
471
+ await ws.send_str(json.dumps(self._handshake(), separators=(",", ":")))
472
+ raw = await asyncio.wait_for(ws.receive(), timeout=self._conn_options.timeout)
473
+ message = _parse_ws_message(
474
+ raw,
475
+ phase="handshake",
476
+ close_code=ws.close_code,
477
+ retryable_on_normal_close=True,
478
+ )
479
+ self._accept_handshake(message)
480
+ except asyncio.CancelledError:
481
+ await _close_quietly(ws)
482
+ raise
483
+ except asyncio.TimeoutError:
484
+ await _close_quietly(ws)
485
+ raise APITimeoutError("Meta Muse realtime ASR handshake timed out") from None
486
+ except APIError:
487
+ await _close_quietly(ws)
488
+ raise
489
+ except Exception as exc:
490
+ await _close_quietly(ws)
491
+ raise APIConnectionError(
492
+ f"Meta Muse realtime ASR handshake failed ({type(exc).__name__})"
493
+ ) from None
494
+
495
+ self._report_connection_acquired(time.perf_counter() - started_at, False)
496
+ return ws
497
+
498
+ def _handshake(self) -> dict[str, object]:
499
+ handshake: dict[str, object] = {
500
+ "mode": "ENDPOINTING",
501
+ "authorization": {"accessToken": self._api_key},
502
+ "audioEncoding": "PCM_24KHZ",
503
+ "model": self._model,
504
+ "partialMode": "CUMULATIVE",
505
+ "emitAudioProgress": True,
506
+ }
507
+ if self._keywords:
508
+ handshake["keywords"] = self._keywords
509
+ if self._language_bias:
510
+ handshake["languageBias"] = self._language_bias
511
+ return handshake
512
+
513
+ def _accept_handshake(self, message: dict[str, Any]) -> None:
514
+ if message.get("type") == "error":
515
+ raise _server_error(phase="handshake")
516
+ session_id = message.get("sessionId")
517
+ if not isinstance(session_id, str) or not session_id:
518
+ raise APIConnectionError(
519
+ "Meta Muse realtime ASR sent an invalid handshake response",
520
+ retryable=False,
521
+ )
522
+ self._session_id = session_id
523
+
524
+ async def _drive_tasks(self, sender: asyncio.Task[None], receiver: asyncio.Task[None]) -> None:
525
+ done, _ = await asyncio.wait((sender, receiver), return_when=asyncio.FIRST_COMPLETED)
526
+ if receiver in done:
527
+ receiver.result()
528
+ if not self._end_stream_sent:
529
+ raise APIConnectionError(
530
+ "Meta Muse realtime ASR closed before input ended",
531
+ retryable=not self._audio_consumed,
532
+ )
533
+ if not sender.done():
534
+ await sender
535
+ else:
536
+ sender.result()
537
+ try:
538
+ await asyncio.wait_for(receiver, timeout=self._conn_options.timeout)
539
+ except asyncio.TimeoutError:
540
+ raise APITimeoutError(
541
+ "Meta Muse realtime ASR timed out while draining final events",
542
+ retryable=not self._audio_consumed,
543
+ ) from None
544
+ self._validate_clean_close()
545
+
546
+ async def _send_audio(self, ws: aiohttp.ClientWebSocketResponse) -> None:
547
+ pending = bytearray()
548
+ loop = asyncio.get_running_loop()
549
+ pacing_origin: float | None = None
550
+ sent_duration = 0.0
551
+
552
+ async def send_packet(packet: bytes) -> None:
553
+ nonlocal pacing_origin, sent_duration
554
+ if not packet:
555
+ return
556
+ if pacing_origin is None:
557
+ pacing_origin = loop.time()
558
+ deadline = pacing_origin + sent_duration
559
+ delay = deadline - loop.time()
560
+ if delay > 0:
561
+ await asyncio.sleep(delay)
562
+ try:
563
+ await ws.send_bytes(packet)
564
+ except Exception as exc:
565
+ raise APIConnectionError(
566
+ f"Meta Muse realtime ASR audio send failed ({type(exc).__name__})",
567
+ retryable=False,
568
+ ) from None
569
+ duration = len(packet) / (_SAMPLE_RATE * _CHANNELS * _SAMPLE_WIDTH_BYTES)
570
+ sent_duration += duration
571
+
572
+ async for item in self._input_ch:
573
+ if isinstance(item, self._FlushSentinel):
574
+ if pending:
575
+ await send_packet(bytes(pending))
576
+ pending.clear()
577
+ continue
578
+
579
+ self._audio_consumed = True
580
+ if item.num_channels != _CHANNELS:
581
+ raise APIError("Meta Muse realtime ASR requires mono audio", retryable=False)
582
+ pending.extend(item.data.tobytes())
583
+ while len(pending) >= _CHUNK_BYTES:
584
+ await send_packet(bytes(pending[:_CHUNK_BYTES]))
585
+ del pending[:_CHUNK_BYTES]
586
+
587
+ if pending:
588
+ await send_packet(bytes(pending))
589
+ if not self._end_stream_sent:
590
+ try:
591
+ await ws.send_str('{"type":"endStream"}')
592
+ except Exception as exc:
593
+ raise APIConnectionError(
594
+ f"Meta Muse realtime ASR end-of-input send failed ({type(exc).__name__})",
595
+ retryable=False,
596
+ ) from None
597
+ self._end_stream_sent = True
598
+
599
+ async def _receive_events(self, ws: aiohttp.ClientWebSocketResponse) -> None:
600
+ while True:
601
+ try:
602
+ raw = await ws.receive()
603
+ except Exception as exc:
604
+ raise APIConnectionError(
605
+ f"Meta Muse realtime ASR receive failed ({type(exc).__name__})",
606
+ retryable=not self._audio_consumed,
607
+ ) from None
608
+
609
+ if raw.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED):
610
+ close_code = raw.data if isinstance(raw.data, int) else ws.close_code
611
+ if self._end_stream_sent and close_code == 1000:
612
+ return
613
+ raise _close_error(
614
+ close_code,
615
+ phase="stream",
616
+ retryable_on_normal_close=not self._audio_consumed,
617
+ )
618
+ if raw.type == aiohttp.WSMsgType.CLOSING:
619
+ continue
620
+ if raw.type == aiohttp.WSMsgType.ERROR:
621
+ raise APIConnectionError(
622
+ "Meta Muse realtime ASR WebSocket failed",
623
+ retryable=not self._audio_consumed,
624
+ ) from None
625
+
626
+ message = _parse_ws_message(raw, phase="stream")
627
+ event_type = message.get("type")
628
+ if event_type == "error":
629
+ raise _server_error(phase="stream")
630
+ if event_type == "speechStart":
631
+ self._speech_start(message)
632
+ elif event_type == "transcript":
633
+ self._transcript(message)
634
+ elif event_type == "speechEnd":
635
+ self._speech_end(message)
636
+ elif event_type == "speechComplete":
637
+ self._speech_complete(message)
638
+ elif event_type == "audioProgress":
639
+ self._audio_progress(message)
640
+
641
+ def _audio_progress(self, message: dict[str, Any]) -> None:
642
+ processed_ms = message.get("audioProcessedMs")
643
+ if (
644
+ isinstance(processed_ms, bool)
645
+ or not isinstance(processed_ms, (int, float))
646
+ or not math.isfinite(processed_ms)
647
+ or processed_ms < 0
648
+ ):
649
+ raise _protocol_error("audioProgress event has invalid audioProcessedMs")
650
+ if processed_ms <= self._last_audio_processed_ms:
651
+ return
652
+ delta_seconds = (processed_ms - self._last_audio_processed_ms) / 1000
653
+ self._last_audio_processed_ms = float(processed_ms)
654
+ self._pending_usage_seconds += delta_seconds
655
+
656
+ def _speech_start(self, message: dict[str, Any]) -> None:
657
+ turn_id = self._required_turn_id(message, event="speechStart")
658
+ if turn_id in self._completed_turn_ids:
659
+ return
660
+ turn = self._turns.setdefault(turn_id, _TurnState())
661
+ turn.provider_started = True
662
+ self._provider_active_turn_id = turn_id
663
+ self._drain_turns()
664
+
665
+ def _transcript(self, message: dict[str, Any]) -> None:
666
+ text = message.get("transcript")
667
+ if not isinstance(text, str):
668
+ raise _protocol_error("transcript event has invalid text")
669
+ if not text and message.get("turnId") is None and self._provider_active_turn_id is None:
670
+ return
671
+ turn_id = self._transcript_turn_id(message)
672
+ if turn_id in self._completed_turn_ids:
673
+ return
674
+ turn = self._turns.setdefault(turn_id, _TurnState())
675
+ if turn.final_text is not None or turn.latest_interim == text:
676
+ return
677
+ turn.latest_interim = text
678
+ self._drain_turns()
679
+
680
+ def _speech_end(self, message: dict[str, Any]) -> None:
681
+ turn_id = self._required_turn_id(message, event="speechEnd")
682
+ if turn_id in self._completed_turn_ids:
683
+ return
684
+ turn = self._turns.setdefault(turn_id, _TurnState())
685
+ turn.ended = True
686
+ if self._provider_active_turn_id == turn_id:
687
+ self._provider_active_turn_id = None
688
+ self._capture_turn_usage(turn)
689
+ self._drain_turns()
690
+
691
+ def _speech_complete(self, message: dict[str, Any]) -> None:
692
+ turn_id = self._required_turn_id(message, event="speechComplete")
693
+ if turn_id in self._completed_turn_ids:
694
+ return
695
+ text = message.get("transcript")
696
+ if not isinstance(text, str):
697
+ raise _protocol_error("speechComplete event has invalid transcript")
698
+ turn = self._turns.setdefault(turn_id, _TurnState())
699
+ if turn.final_text is None:
700
+ turn.final_text = text
701
+ self._capture_turn_usage(turn)
702
+ self._drain_turns()
703
+
704
+ def _capture_turn_usage(self, turn: _TurnState) -> None:
705
+ if turn.usage_captured or not turn.ended:
706
+ return
707
+ turn.usage_seconds = self._pending_usage_seconds
708
+ turn.usage_captured = True
709
+ self._pending_usage_seconds = 0.0
710
+
711
+ def _drain_turns(self) -> None:
712
+ while self._turns:
713
+ turn_id = next(iter(self._turns))
714
+ turn = self._turns[turn_id]
715
+ has_content = turn.latest_interim is not None or turn.final_text is not None
716
+ if not turn.emitted_start and (turn.provider_started or has_content):
717
+ turn.emitted_start = True
718
+ self._emit(stt.SpeechEventType.START_OF_SPEECH, turn_id)
719
+
720
+ if (
721
+ turn.emitted_start
722
+ and not turn.final_emitted
723
+ and turn.latest_interim is not None
724
+ and turn.latest_interim != turn.emitted_interim
725
+ ):
726
+ turn.emitted_interim = turn.latest_interim
727
+ self._emit(
728
+ stt.SpeechEventType.INTERIM_TRANSCRIPT,
729
+ turn_id,
730
+ turn.latest_interim,
731
+ )
732
+
733
+ if turn.emitted_start and not turn.final_emitted and turn.final_text is not None:
734
+ turn.final_emitted = True
735
+ self._emit(stt.SpeechEventType.FINAL_TRANSCRIPT, turn_id, turn.final_text)
736
+
737
+ if not (turn.final_emitted and turn.ended):
738
+ return
739
+
740
+ self._emit(stt.SpeechEventType.END_OF_SPEECH, turn_id)
741
+ self._emit_usage(turn.usage_seconds)
742
+ del self._turns[turn_id]
743
+ self._remember_completed_turn(turn_id)
744
+
745
+ def _remember_completed_turn(self, turn_id: str) -> None:
746
+ if turn_id in self._completed_turn_ids:
747
+ return
748
+ if len(self._completed_turn_order) >= _MAX_COMPLETED_TURNS:
749
+ oldest = self._completed_turn_order.popleft()
750
+ self._completed_turn_ids.discard(oldest)
751
+ self._completed_turn_order.append(turn_id)
752
+ self._completed_turn_ids.add(turn_id)
753
+
754
+ def _emit(self, event_type: stt.SpeechEventType, turn_id: str, text: str | None = None) -> None:
755
+ alternatives: list[stt.SpeechData] = []
756
+ if text is not None:
757
+ alternatives.append(stt.SpeechData(language=LanguageCode(""), text=text))
758
+ self._event_ch.send_nowait(
759
+ stt.SpeechEvent(type=event_type, request_id=turn_id, alternatives=alternatives)
760
+ )
761
+
762
+ def _emit_usage(self, duration: float) -> None:
763
+ if duration <= 0:
764
+ return
765
+ self._event_ch.send_nowait(
766
+ stt.SpeechEvent(
767
+ type=stt.SpeechEventType.RECOGNITION_USAGE,
768
+ request_id=self._session_id,
769
+ recognition_usage=stt.RecognitionUsage(audio_duration=duration),
770
+ )
771
+ )
772
+
773
+ def _flush_usage(self) -> None:
774
+ duration = self._pending_usage_seconds
775
+ self._pending_usage_seconds = 0.0
776
+ for turn in self._turns.values():
777
+ duration += turn.usage_seconds
778
+ turn.usage_seconds = 0.0
779
+ self._emit_usage(duration)
780
+
781
+ def _transcript_turn_id(self, message: dict[str, Any]) -> str:
782
+ value = message.get("turnId")
783
+ if value is not None:
784
+ return _normalize_turn_id(value, event="transcript")
785
+
786
+ if self._provider_active_turn_id is not None:
787
+ return self._provider_active_turn_id
788
+ raise _protocol_error("transcript event is missing turnId outside an active turn")
789
+
790
+ def _required_turn_id(self, message: dict[str, Any], *, event: str) -> str:
791
+ value = message.get("turnId")
792
+ if value is None:
793
+ raise _protocol_error(f"{event} event is missing turnId")
794
+ return _normalize_turn_id(value, event=event)
795
+
796
+ def _validate_clean_close(self) -> None:
797
+ if self._turns:
798
+ raise APIConnectionError(
799
+ "Meta Muse realtime ASR closed with incomplete speech turns",
800
+ retryable=False,
801
+ )
@@ -0,0 +1,15 @@
1
+ # Copyright 2023 LiveKit, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ __version__ = "1.8.1"
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "livekit-plugins-meta"
7
+ dynamic = ["version"]
8
+ description = "Meta Muse Voice Transcribe plugin for LiveKit Agents"
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.10.0"
12
+ authors = [{ name = "LiveKit", email = "hello@livekit.io" }]
13
+ keywords = [
14
+ "webrtc",
15
+ "realtime",
16
+ "audio",
17
+ "livekit",
18
+ "meta",
19
+ "muse",
20
+ "speech-to-text",
21
+ "stt",
22
+ ]
23
+ classifiers = [
24
+ "Intended Audience :: Developers",
25
+ "License :: OSI Approved :: Apache Software License",
26
+ "Topic :: Multimedia :: Sound/Audio",
27
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
28
+ "Programming Language :: Python :: 3",
29
+ "Programming Language :: Python :: 3.10",
30
+ "Programming Language :: Python :: 3 :: Only",
31
+ ]
32
+ dependencies = ["livekit-agents>=1.8.1"]
33
+
34
+ [project.urls]
35
+ Documentation = "https://docs.livekit.io"
36
+ Website = "https://livekit.io/"
37
+ Source = "https://github.com/livekit/agents"
38
+
39
+ [tool.hatch.version]
40
+ path = "livekit/plugins/meta/version.py"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["livekit"]
44
+
45
+ [tool.hatch.build.targets.sdist]
46
+ include = ["/livekit"]
47
+
48
+ [tool.uv]
49
+ exclude-newer = "7 days"
50
+ exclude-newer-package = { livekit-agents = "0 days" }