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.
- livekit_plugins_meta-1.8.1/.gitignore +180 -0
- livekit_plugins_meta-1.8.1/PKG-INFO +62 -0
- livekit_plugins_meta-1.8.1/README.md +41 -0
- livekit_plugins_meta-1.8.1/livekit/plugins/meta/__init__.py +40 -0
- livekit_plugins_meta-1.8.1/livekit/plugins/meta/log.py +17 -0
- livekit_plugins_meta-1.8.1/livekit/plugins/meta/py.typed +0 -0
- livekit_plugins_meta-1.8.1/livekit/plugins/meta/stt.py +801 -0
- livekit_plugins_meta-1.8.1/livekit/plugins/meta/version.py +15 -0
- livekit_plugins_meta-1.8.1/pyproject.toml +50 -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,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")
|
|
File without changes
|
|
@@ -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" }
|