livekit-plugins-aliyun 1.1.0.post4__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_aliyun-1.1.0.post4/.gitignore +183 -0
- livekit_plugins_aliyun-1.1.0.post4/PKG-INFO +65 -0
- livekit_plugins_aliyun-1.1.0.post4/README.md +44 -0
- livekit_plugins_aliyun-1.1.0.post4/livekit/plugins/aliyun/__init__.py +27 -0
- livekit_plugins_aliyun-1.1.0.post4/livekit/plugins/aliyun/llm.py +288 -0
- livekit_plugins_aliyun-1.1.0.post4/livekit/plugins/aliyun/log.py +3 -0
- livekit_plugins_aliyun-1.1.0.post4/livekit/plugins/aliyun/py.typed +0 -0
- livekit_plugins_aliyun-1.1.0.post4/livekit/plugins/aliyun/stt.py +174 -0
- livekit_plugins_aliyun-1.1.0.post4/livekit/plugins/aliyun/tts.py +260 -0
- livekit_plugins_aliyun-1.1.0.post4/livekit/plugins/aliyun/utils.py +151 -0
- livekit_plugins_aliyun-1.1.0.post4/livekit/plugins/aliyun/version.py +1 -0
- livekit_plugins_aliyun-1.1.0.post4/pyproject.toml +42 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py,cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
#Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
#uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
#poetry.lock
|
|
109
|
+
|
|
110
|
+
# pdm
|
|
111
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
112
|
+
#pdm.lock
|
|
113
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
114
|
+
# in version control.
|
|
115
|
+
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
|
116
|
+
.pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
121
|
+
__pypackages__/
|
|
122
|
+
|
|
123
|
+
# Celery stuff
|
|
124
|
+
celerybeat-schedule
|
|
125
|
+
celerybeat.pid
|
|
126
|
+
|
|
127
|
+
# SageMath parsed files
|
|
128
|
+
*.sage.py
|
|
129
|
+
|
|
130
|
+
# Environments
|
|
131
|
+
.env
|
|
132
|
+
.venv
|
|
133
|
+
env/
|
|
134
|
+
venv/
|
|
135
|
+
ENV/
|
|
136
|
+
env.bak/
|
|
137
|
+
venv.bak/
|
|
138
|
+
|
|
139
|
+
# Spyder project settings
|
|
140
|
+
.spyderproject
|
|
141
|
+
.spyproject
|
|
142
|
+
|
|
143
|
+
# Rope project settings
|
|
144
|
+
.ropeproject
|
|
145
|
+
|
|
146
|
+
# mkdocs documentation
|
|
147
|
+
/site
|
|
148
|
+
|
|
149
|
+
# mypy
|
|
150
|
+
.mypy_cache/
|
|
151
|
+
.dmypy.json
|
|
152
|
+
dmypy.json
|
|
153
|
+
|
|
154
|
+
# Pyre type checker
|
|
155
|
+
.pyre/
|
|
156
|
+
|
|
157
|
+
# pytype static type analyzer
|
|
158
|
+
.pytype/
|
|
159
|
+
|
|
160
|
+
# Cython debug symbols
|
|
161
|
+
cython_debug/
|
|
162
|
+
|
|
163
|
+
# PyCharm
|
|
164
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
165
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
166
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
167
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
168
|
+
#.idea/
|
|
169
|
+
|
|
170
|
+
# Ruff stuff:
|
|
171
|
+
.ruff_cache/
|
|
172
|
+
|
|
173
|
+
# PyPI configuration file
|
|
174
|
+
.pypirc
|
|
175
|
+
|
|
176
|
+
# tests
|
|
177
|
+
test.py
|
|
178
|
+
|
|
179
|
+
.python-version
|
|
180
|
+
|
|
181
|
+
*.ipynb
|
|
182
|
+
config.yaml
|
|
183
|
+
uv.lock
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: livekit-plugins-aliyun
|
|
3
|
+
Version: 1.1.0.post4
|
|
4
|
+
Summary: LiveKit Agent Plugins for Alibaba Cloud
|
|
5
|
+
Author-email: wangmengdi <790990241@qq.com>
|
|
6
|
+
Keywords: audio,livekit,realtime,video,webrtc
|
|
7
|
+
Classifier: Intended Audience :: Developers
|
|
8
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
14
|
+
Classifier: Topic :: Multimedia :: Video
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Requires-Dist: dashscope
|
|
18
|
+
Requires-Dist: livekit-agents~=1.1.0
|
|
19
|
+
Requires-Dist: osc-data
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# livekit-plugins-aliyun
|
|
23
|
+
|
|
24
|
+
适配[阿里云百炼](https://bailian.console.aliyun.com/?spm=5176.29597918.J_SEsSjsNv72yRuRFS2VknO.2.6b887b08kVpe2w&tab=model#/model-market)的[livekit-agent](https://github.com/livekit/agents)框架插件。目前支持[TTS](https://bailian.console.aliyun.com/?spm=5176.29597918.J_SEsSjsNv72yRuRFS2VknO.2.6b887b08kVpe2w&tab=model#/model-market?capabilities=%5B%22TTS%22%5D&z_type_=%7B%22capabilities%22%3A%22array%22%7D), [LLM](https://bailian.console.aliyun.com/?tab=model#/model-market), [STT](https://bailian.console.aliyun.com/?spm=5176.29597918.J_SEsSjsNv72yRuRFS2VknO.2.6b887b08kVpe2w&tab=model#/model-market?capabilities=%5B%22ASR%22%5D&z_type_=%7B%22capabilities%22%3A%22array%22%7D)。
|
|
25
|
+
|
|
26
|
+
## 安装
|
|
27
|
+
```python
|
|
28
|
+
pip install livekit-plugins-aliyun
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## 环境变量
|
|
32
|
+
|
|
33
|
+
- LLM, STT, TTS: `DASHSCOPE_API_KEY`
|
|
34
|
+
|
|
35
|
+
## 使用示例
|
|
36
|
+
|
|
37
|
+
以下代码展示了如何在`livekit-agent`中使用`livekit-plugins-aliyun`插件。
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from livekit.agents import Agent, AgentSession, JobContext, cli, WorkerOptions
|
|
41
|
+
from livekit.plugins import aliyun
|
|
42
|
+
from dotenv import load_dotenv
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def entry_point(ctx: JobContext):
|
|
46
|
+
|
|
47
|
+
await ctx.connect()
|
|
48
|
+
|
|
49
|
+
agent = Agent(instructions="You are a helpful assistant.")
|
|
50
|
+
|
|
51
|
+
session = AgentSession(
|
|
52
|
+
stt=aliyun.STT(model="paraformer-realtime-v2"),
|
|
53
|
+
tts=aliyun.TTS(model="cosyvoice-v2", voice="longcheng_v2"),
|
|
54
|
+
llm=aliyun.LLM(model="qwen-plus"),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
await session.start(agent=agent, room=ctx.room)
|
|
58
|
+
|
|
59
|
+
await session.generate_reply()
|
|
60
|
+
|
|
61
|
+
if __name__ == "__main__":
|
|
62
|
+
load_dotenv()
|
|
63
|
+
cli.run_app(WorkerOptions(entrypoint_fnc=entry_point))
|
|
64
|
+
```
|
|
65
|
+
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# livekit-plugins-aliyun
|
|
2
|
+
|
|
3
|
+
适配[阿里云百炼](https://bailian.console.aliyun.com/?spm=5176.29597918.J_SEsSjsNv72yRuRFS2VknO.2.6b887b08kVpe2w&tab=model#/model-market)的[livekit-agent](https://github.com/livekit/agents)框架插件。目前支持[TTS](https://bailian.console.aliyun.com/?spm=5176.29597918.J_SEsSjsNv72yRuRFS2VknO.2.6b887b08kVpe2w&tab=model#/model-market?capabilities=%5B%22TTS%22%5D&z_type_=%7B%22capabilities%22%3A%22array%22%7D), [LLM](https://bailian.console.aliyun.com/?tab=model#/model-market), [STT](https://bailian.console.aliyun.com/?spm=5176.29597918.J_SEsSjsNv72yRuRFS2VknO.2.6b887b08kVpe2w&tab=model#/model-market?capabilities=%5B%22ASR%22%5D&z_type_=%7B%22capabilities%22%3A%22array%22%7D)。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
```python
|
|
7
|
+
pip install livekit-plugins-aliyun
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## 环境变量
|
|
11
|
+
|
|
12
|
+
- LLM, STT, TTS: `DASHSCOPE_API_KEY`
|
|
13
|
+
|
|
14
|
+
## 使用示例
|
|
15
|
+
|
|
16
|
+
以下代码展示了如何在`livekit-agent`中使用`livekit-plugins-aliyun`插件。
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from livekit.agents import Agent, AgentSession, JobContext, cli, WorkerOptions
|
|
20
|
+
from livekit.plugins import aliyun
|
|
21
|
+
from dotenv import load_dotenv
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def entry_point(ctx: JobContext):
|
|
25
|
+
|
|
26
|
+
await ctx.connect()
|
|
27
|
+
|
|
28
|
+
agent = Agent(instructions="You are a helpful assistant.")
|
|
29
|
+
|
|
30
|
+
session = AgentSession(
|
|
31
|
+
stt=aliyun.STT(model="paraformer-realtime-v2"),
|
|
32
|
+
tts=aliyun.TTS(model="cosyvoice-v2", voice="longcheng_v2"),
|
|
33
|
+
llm=aliyun.LLM(model="qwen-plus"),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
await session.start(agent=agent, room=ctx.room)
|
|
37
|
+
|
|
38
|
+
await session.generate_reply()
|
|
39
|
+
|
|
40
|
+
if __name__ == "__main__":
|
|
41
|
+
load_dotenv()
|
|
42
|
+
cli.run_app(WorkerOptions(entrypoint_fnc=entry_point))
|
|
43
|
+
```
|
|
44
|
+
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from .llm import LLM
|
|
2
|
+
from .stt import STT
|
|
3
|
+
from .tts import TTS
|
|
4
|
+
from .version import __version__
|
|
5
|
+
|
|
6
|
+
__all__ = ["TTS", "LLM", "STT", "__version__"]
|
|
7
|
+
|
|
8
|
+
from livekit.agents import Plugin
|
|
9
|
+
|
|
10
|
+
from .log import logger
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AliyunPlugin(Plugin):
|
|
14
|
+
def __init__(self):
|
|
15
|
+
super().__init__(__name__, __version__, __package__, logger)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
Plugin.register_plugin(AliyunPlugin())
|
|
19
|
+
|
|
20
|
+
# Cleanup docs of unexported modules
|
|
21
|
+
_module = dir()
|
|
22
|
+
NOT_IN_ALL = [m for m in _module if m not in __all__]
|
|
23
|
+
|
|
24
|
+
__pdoc__ = {}
|
|
25
|
+
|
|
26
|
+
for n in NOT_IN_ALL:
|
|
27
|
+
__pdoc__[n] = False
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import openai
|
|
10
|
+
from openai.types.chat import ChatCompletionChunk, ChatCompletionToolChoiceOptionParam
|
|
11
|
+
from openai.types.chat.chat_completion_chunk import Choice
|
|
12
|
+
|
|
13
|
+
from livekit.agents import APIConnectionError, APIStatusError, APITimeoutError, llm
|
|
14
|
+
from livekit.agents.llm import ToolChoice
|
|
15
|
+
from livekit.agents.llm.chat_context import ChatContext
|
|
16
|
+
from livekit.agents.llm.tool_context import FunctionTool
|
|
17
|
+
from livekit.agents.types import (
|
|
18
|
+
DEFAULT_API_CONNECT_OPTIONS,
|
|
19
|
+
NOT_GIVEN,
|
|
20
|
+
APIConnectOptions,
|
|
21
|
+
NotGivenOr,
|
|
22
|
+
)
|
|
23
|
+
from livekit.agents.utils import is_given
|
|
24
|
+
|
|
25
|
+
from .log import logger
|
|
26
|
+
from .utils import to_chat_ctx, to_fnc_ctx
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class _LLMOptions:
|
|
31
|
+
model: str
|
|
32
|
+
user: NotGivenOr[str]
|
|
33
|
+
temperature: NotGivenOr[float]
|
|
34
|
+
parallel_tool_calls: NotGivenOr[bool]
|
|
35
|
+
tool_choice: NotGivenOr[ToolChoice]
|
|
36
|
+
store: NotGivenOr[bool]
|
|
37
|
+
metadata: NotGivenOr[dict[str, str]]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class LLM(llm.LLM):
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
model: str = "qwen-plus",
|
|
45
|
+
api_key: NotGivenOr[str] = NOT_GIVEN,
|
|
46
|
+
client: openai.AsyncClient | None = None,
|
|
47
|
+
user: NotGivenOr[str] = NOT_GIVEN,
|
|
48
|
+
temperature: NotGivenOr[float] = NOT_GIVEN,
|
|
49
|
+
parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN,
|
|
50
|
+
tool_choice: NotGivenOr[ToolChoice] = NOT_GIVEN,
|
|
51
|
+
store: NotGivenOr[bool] = NOT_GIVEN,
|
|
52
|
+
metadata: NotGivenOr[dict[str, str]] = NOT_GIVEN,
|
|
53
|
+
timeout: httpx.Timeout | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""
|
|
56
|
+
Create a new instance of LLM.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
model: The model to use for the LLM, end with -v1.
|
|
60
|
+
api_key: The API key to use for the LLM.
|
|
61
|
+
base_url: The base URL to use for the LLM.
|
|
62
|
+
"""
|
|
63
|
+
super().__init__()
|
|
64
|
+
self._opts = _LLMOptions(
|
|
65
|
+
model=model,
|
|
66
|
+
user=user,
|
|
67
|
+
temperature=temperature,
|
|
68
|
+
parallel_tool_calls=parallel_tool_calls,
|
|
69
|
+
tool_choice=tool_choice,
|
|
70
|
+
store=store,
|
|
71
|
+
metadata=metadata,
|
|
72
|
+
)
|
|
73
|
+
api_key = api_key if is_given(api_key) else os.getenv("DASHSCOPE_API_KEY")
|
|
74
|
+
if api_key is None:
|
|
75
|
+
raise ValueError("environment variable DASHSCOPE_API_KEY is not set")
|
|
76
|
+
self._client = client or openai.AsyncClient(
|
|
77
|
+
api_key=api_key if is_given(api_key) else None,
|
|
78
|
+
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
79
|
+
max_retries=0,
|
|
80
|
+
http_client=httpx.AsyncClient(
|
|
81
|
+
timeout=timeout
|
|
82
|
+
if timeout
|
|
83
|
+
else httpx.Timeout(connect=15.0, read=5.0, write=5.0, pool=5.0),
|
|
84
|
+
follow_redirects=True,
|
|
85
|
+
limits=httpx.Limits(
|
|
86
|
+
max_connections=50,
|
|
87
|
+
max_keepalive_connections=50,
|
|
88
|
+
keepalive_expiry=120,
|
|
89
|
+
),
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def chat(
|
|
94
|
+
self,
|
|
95
|
+
*,
|
|
96
|
+
chat_ctx: ChatContext,
|
|
97
|
+
tools: list[FunctionTool] | None = None,
|
|
98
|
+
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
|
|
99
|
+
parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN,
|
|
100
|
+
tool_choice: NotGivenOr[ToolChoice] = NOT_GIVEN,
|
|
101
|
+
extra_kwargs: NotGivenOr[dict[str, Any]] = NOT_GIVEN,
|
|
102
|
+
) -> LLMStream:
|
|
103
|
+
extra = {}
|
|
104
|
+
|
|
105
|
+
if is_given(extra_kwargs):
|
|
106
|
+
extra.update(extra_kwargs)
|
|
107
|
+
|
|
108
|
+
if is_given(self._opts.metadata):
|
|
109
|
+
extra["metadata"] = self._opts.metadata
|
|
110
|
+
|
|
111
|
+
if is_given(self._opts.user):
|
|
112
|
+
extra["user"] = self._opts.user
|
|
113
|
+
|
|
114
|
+
parallel_tool_calls = (
|
|
115
|
+
parallel_tool_calls
|
|
116
|
+
if is_given(parallel_tool_calls)
|
|
117
|
+
else self._opts.parallel_tool_calls
|
|
118
|
+
)
|
|
119
|
+
if is_given(parallel_tool_calls):
|
|
120
|
+
extra["parallel_tool_calls"] = parallel_tool_calls
|
|
121
|
+
|
|
122
|
+
tool_choice = tool_choice if is_given(tool_choice) else self._opts.tool_choice # type: ignore
|
|
123
|
+
if is_given(tool_choice):
|
|
124
|
+
oai_tool_choice: ChatCompletionToolChoiceOptionParam
|
|
125
|
+
if isinstance(tool_choice, dict):
|
|
126
|
+
oai_tool_choice = {
|
|
127
|
+
"type": "function",
|
|
128
|
+
"function": {"name": tool_choice["function"]["name"]},
|
|
129
|
+
}
|
|
130
|
+
extra["tool_choice"] = oai_tool_choice
|
|
131
|
+
elif tool_choice in ("auto", "required", "none"):
|
|
132
|
+
oai_tool_choice = tool_choice
|
|
133
|
+
extra["tool_choice"] = oai_tool_choice
|
|
134
|
+
logger.info("llm start", extra={"model": self._opts.model})
|
|
135
|
+
return LLMStream(
|
|
136
|
+
self,
|
|
137
|
+
model=self._opts.model,
|
|
138
|
+
client=self._client,
|
|
139
|
+
chat_ctx=chat_ctx,
|
|
140
|
+
tools=tools or [],
|
|
141
|
+
conn_options=conn_options,
|
|
142
|
+
extra_kwargs=extra,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class LLMStream(llm.LLMStream):
|
|
147
|
+
def __init__(
|
|
148
|
+
self,
|
|
149
|
+
llm: LLM,
|
|
150
|
+
*,
|
|
151
|
+
model: str,
|
|
152
|
+
client: openai.AsyncClient,
|
|
153
|
+
chat_ctx: llm.ChatContext,
|
|
154
|
+
tools: list[FunctionTool],
|
|
155
|
+
conn_options: APIConnectOptions,
|
|
156
|
+
extra_kwargs: dict[str, Any],
|
|
157
|
+
) -> None:
|
|
158
|
+
super().__init__(llm, chat_ctx=chat_ctx, tools=tools, conn_options=conn_options)
|
|
159
|
+
self._model = model
|
|
160
|
+
self._client = client
|
|
161
|
+
self._llm = llm
|
|
162
|
+
self._extra_kwargs = extra_kwargs
|
|
163
|
+
|
|
164
|
+
async def _run(self) -> None:
|
|
165
|
+
# current function call that we're waiting for full completion (args are streamed)
|
|
166
|
+
# (defined inside the _run method to make sure the state is reset for each run/attempt)
|
|
167
|
+
self._oai_stream: openai.AsyncStream[ChatCompletionChunk] | None = None
|
|
168
|
+
self._tool_call_id: str | None = None
|
|
169
|
+
self._fnc_name: str | None = None
|
|
170
|
+
self._fnc_raw_arguments: str | None = None
|
|
171
|
+
self._tool_index: int | None = None
|
|
172
|
+
retryable = True
|
|
173
|
+
first_response = True
|
|
174
|
+
start = time.perf_counter()
|
|
175
|
+
try:
|
|
176
|
+
stream: openai.AsyncStream[
|
|
177
|
+
ChatCompletionChunk
|
|
178
|
+
] = await self._client.chat.completions.create(
|
|
179
|
+
messages=to_chat_ctx(self._chat_ctx, id(self._llm)),
|
|
180
|
+
tools=to_fnc_ctx(self._tools) if self._tools else openai.NOT_GIVEN,
|
|
181
|
+
model=self._model,
|
|
182
|
+
stream_options={"include_usage": True},
|
|
183
|
+
stream=True,
|
|
184
|
+
**self._extra_kwargs,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
async with stream:
|
|
188
|
+
async for chunk in stream:
|
|
189
|
+
for choice in chunk.choices:
|
|
190
|
+
chat_chunk = self._parse_choice(chunk.id, choice)
|
|
191
|
+
if chat_chunk is not None:
|
|
192
|
+
retryable = False
|
|
193
|
+
self._event_ch.send_nowait(chat_chunk)
|
|
194
|
+
if first_response:
|
|
195
|
+
spent = time.perf_counter() - start
|
|
196
|
+
logger.info(
|
|
197
|
+
"llm first response", extra={"spent": round(spent, 4)}
|
|
198
|
+
)
|
|
199
|
+
first_response = False
|
|
200
|
+
|
|
201
|
+
if chunk.usage is not None:
|
|
202
|
+
retryable = False
|
|
203
|
+
chunk = llm.ChatChunk(
|
|
204
|
+
id=chunk.id,
|
|
205
|
+
usage=llm.CompletionUsage(
|
|
206
|
+
completion_tokens=chunk.usage.completion_tokens,
|
|
207
|
+
prompt_tokens=chunk.usage.prompt_tokens,
|
|
208
|
+
total_tokens=chunk.usage.total_tokens,
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
self._event_ch.send_nowait(chunk)
|
|
212
|
+
logger.info("llm end")
|
|
213
|
+
|
|
214
|
+
except openai.APITimeoutError:
|
|
215
|
+
raise APITimeoutError(retryable=retryable) # noqa: B904
|
|
216
|
+
except openai.APIStatusError as e:
|
|
217
|
+
raise APIStatusError( # noqa: B904
|
|
218
|
+
e.message,
|
|
219
|
+
status_code=e.status_code,
|
|
220
|
+
request_id=e.request_id,
|
|
221
|
+
body=e.body,
|
|
222
|
+
retryable=retryable,
|
|
223
|
+
)
|
|
224
|
+
except Exception as e:
|
|
225
|
+
raise APIConnectionError(retryable=retryable) from e
|
|
226
|
+
|
|
227
|
+
def _parse_choice(self, id: str, choice: Choice) -> llm.ChatChunk | None:
|
|
228
|
+
delta = choice.delta
|
|
229
|
+
|
|
230
|
+
if delta is None:
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
if delta.tool_calls:
|
|
234
|
+
for tool in delta.tool_calls:
|
|
235
|
+
if not tool.function:
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
call_chunk = None
|
|
239
|
+
if self._tool_call_id and tool.id and tool.index != self._tool_index:
|
|
240
|
+
call_chunk = llm.ChatChunk(
|
|
241
|
+
id=id,
|
|
242
|
+
delta=llm.ChoiceDelta(
|
|
243
|
+
role="assistant",
|
|
244
|
+
content=delta.content,
|
|
245
|
+
tool_calls=[
|
|
246
|
+
llm.FunctionToolCall(
|
|
247
|
+
arguments=self._fnc_raw_arguments or "",
|
|
248
|
+
name=self._fnc_name or "",
|
|
249
|
+
call_id=self._tool_call_id or "",
|
|
250
|
+
)
|
|
251
|
+
],
|
|
252
|
+
),
|
|
253
|
+
)
|
|
254
|
+
self._tool_call_id = self._fnc_name = self._fnc_raw_arguments = None
|
|
255
|
+
|
|
256
|
+
if tool.function.name:
|
|
257
|
+
self._tool_index = tool.index
|
|
258
|
+
self._tool_call_id = tool.id
|
|
259
|
+
self._fnc_name = tool.function.name
|
|
260
|
+
self._fnc_raw_arguments = tool.function.arguments or ""
|
|
261
|
+
elif tool.function.arguments:
|
|
262
|
+
self._fnc_raw_arguments += tool.function.arguments # type: ignore
|
|
263
|
+
|
|
264
|
+
if call_chunk is not None:
|
|
265
|
+
return call_chunk
|
|
266
|
+
|
|
267
|
+
if choice.finish_reason in ("tool_calls", "stop") and self._tool_call_id:
|
|
268
|
+
call_chunk = llm.ChatChunk(
|
|
269
|
+
id=id,
|
|
270
|
+
delta=llm.ChoiceDelta(
|
|
271
|
+
role="assistant",
|
|
272
|
+
content=delta.content,
|
|
273
|
+
tool_calls=[
|
|
274
|
+
llm.FunctionToolCall(
|
|
275
|
+
arguments=self._fnc_raw_arguments or "",
|
|
276
|
+
name=self._fnc_name or "",
|
|
277
|
+
call_id=self._tool_call_id or "",
|
|
278
|
+
)
|
|
279
|
+
],
|
|
280
|
+
),
|
|
281
|
+
)
|
|
282
|
+
self._tool_call_id = self._fnc_name = self._fnc_raw_arguments = None
|
|
283
|
+
return call_chunk
|
|
284
|
+
|
|
285
|
+
return llm.ChatChunk(
|
|
286
|
+
id=id,
|
|
287
|
+
delta=llm.ChoiceDelta(content=delta.content, role="assistant"),
|
|
288
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import os
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
from dashscope.audio.asr import Recognition, RecognitionCallback, RecognitionResult
|
|
8
|
+
|
|
9
|
+
from livekit import rtc
|
|
10
|
+
from livekit.agents import stt, utils, APIConnectOptions, DEFAULT_API_CONNECT_OPTIONS
|
|
11
|
+
from livekit.agents.types import (
|
|
12
|
+
NOT_GIVEN,
|
|
13
|
+
NotGivenOr,
|
|
14
|
+
)
|
|
15
|
+
from .log import logger
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class STTOptions:
|
|
20
|
+
api_key: str | None
|
|
21
|
+
language: str | None
|
|
22
|
+
detect_language: bool
|
|
23
|
+
interim_results: bool
|
|
24
|
+
punctuate: bool
|
|
25
|
+
model: str
|
|
26
|
+
smart_format: bool
|
|
27
|
+
endpointing: int | None
|
|
28
|
+
sample_rate: int = 16000
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class STT(stt.STT):
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
language="zh",
|
|
36
|
+
detect_language: bool = False,
|
|
37
|
+
interim_results: bool = True,
|
|
38
|
+
punctuate: bool = True,
|
|
39
|
+
smart_format: bool = True,
|
|
40
|
+
model: str = "paraformer-realtime-v2",
|
|
41
|
+
api_key: str | None = None,
|
|
42
|
+
min_silence_duration: int = 500,
|
|
43
|
+
) -> None:
|
|
44
|
+
super().__init__(
|
|
45
|
+
capabilities=stt.STTCapabilities(
|
|
46
|
+
streaming=True, interim_results=interim_results
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
api_key = api_key or os.environ.get("DASHSCOPE_API_KEY")
|
|
50
|
+
if api_key is None:
|
|
51
|
+
raise ValueError("DASHSCOPE API key is required")
|
|
52
|
+
self._opts = STTOptions(
|
|
53
|
+
api_key=api_key,
|
|
54
|
+
language=language,
|
|
55
|
+
detect_language=detect_language,
|
|
56
|
+
interim_results=interim_results,
|
|
57
|
+
punctuate=punctuate,
|
|
58
|
+
model=model,
|
|
59
|
+
smart_format=smart_format,
|
|
60
|
+
endpointing=min_silence_duration,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
async def _recognize_impl(
|
|
64
|
+
self,
|
|
65
|
+
buffer: utils.AudioBuffer,
|
|
66
|
+
*,
|
|
67
|
+
language: NotGivenOr[str] = NOT_GIVEN,
|
|
68
|
+
conn_options: APIConnectOptions,
|
|
69
|
+
) -> stt.SpeechEvent:
|
|
70
|
+
raise NotImplementedError("not implemented")
|
|
71
|
+
|
|
72
|
+
def stream(
|
|
73
|
+
self,
|
|
74
|
+
*,
|
|
75
|
+
language: str | None = None,
|
|
76
|
+
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
|
|
77
|
+
) -> "SpeechStream":
|
|
78
|
+
return SpeechStream(stt=self, opts=self._opts, conn_options=conn_options)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class SpeechStream(stt.SpeechStream):
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
stt: STT,
|
|
85
|
+
opts: STTOptions,
|
|
86
|
+
conn_options: APIConnectOptions,
|
|
87
|
+
) -> None:
|
|
88
|
+
super().__init__(stt=stt, conn_options=conn_options)
|
|
89
|
+
|
|
90
|
+
if opts.language is None:
|
|
91
|
+
raise ValueError("language detection is not supported in streaming mode")
|
|
92
|
+
self._opts: STTOptions = opts
|
|
93
|
+
self._config = opts
|
|
94
|
+
self._speaking = False
|
|
95
|
+
self.recognition = Recognition(
|
|
96
|
+
model=opts.model,
|
|
97
|
+
format="pcm",
|
|
98
|
+
sample_rate=opts.sample_rate,
|
|
99
|
+
callback=Callback(self),
|
|
100
|
+
disfluency_removal_enabled=True,
|
|
101
|
+
semantic_punctuation_enabled=False,
|
|
102
|
+
max_sentence_silence=opts.endpointing,
|
|
103
|
+
language_hints=[opts.language],
|
|
104
|
+
)
|
|
105
|
+
self._closed = False
|
|
106
|
+
self._request_id = utils.shortuuid()
|
|
107
|
+
self._reconnect_event = asyncio.Event()
|
|
108
|
+
|
|
109
|
+
async def _run(self) -> None:
|
|
110
|
+
self.recognition.start()
|
|
111
|
+
samples_100ms = self._opts.sample_rate // 10
|
|
112
|
+
audio_bstream = utils.audio.AudioByteStream(
|
|
113
|
+
sample_rate=self._opts.sample_rate,
|
|
114
|
+
num_channels=1,
|
|
115
|
+
samples_per_channel=samples_100ms,
|
|
116
|
+
)
|
|
117
|
+
while True:
|
|
118
|
+
try:
|
|
119
|
+
has_ended = False
|
|
120
|
+
async for data in self._input_ch:
|
|
121
|
+
frames: list[rtc.AudioFrame] = []
|
|
122
|
+
if isinstance(data, rtc.AudioFrame):
|
|
123
|
+
frames.extend(audio_bstream.write(data.data.tobytes()))
|
|
124
|
+
elif isinstance(data, self._FlushSentinel):
|
|
125
|
+
frames.extend(audio_bstream.flush())
|
|
126
|
+
has_ended = True
|
|
127
|
+
for frame in frames:
|
|
128
|
+
self.recognition.send_audio_frame(frame.data.tobytes())
|
|
129
|
+
if has_ended:
|
|
130
|
+
self.recognition.stop()
|
|
131
|
+
finally:
|
|
132
|
+
self.recognition.stop()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def live_transcription_to_speech_data(
|
|
136
|
+
language: str,
|
|
137
|
+
data,
|
|
138
|
+
) -> List[stt.SpeechData]:
|
|
139
|
+
return [
|
|
140
|
+
stt.SpeechData(
|
|
141
|
+
language=language,
|
|
142
|
+
start_time=data["begin_time"],
|
|
143
|
+
end_time=data["end_time"],
|
|
144
|
+
confidence=0.0,
|
|
145
|
+
text=data["text"],
|
|
146
|
+
)
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class Callback(RecognitionCallback):
|
|
151
|
+
def __init__(self, _stt: SpeechStream):
|
|
152
|
+
self._stt = _stt
|
|
153
|
+
|
|
154
|
+
def on_event(self, result: RecognitionResult) -> None:
|
|
155
|
+
sentence = result.get_sentence()
|
|
156
|
+
dg_alts = live_transcription_to_speech_data(
|
|
157
|
+
self._stt._config.language, sentence
|
|
158
|
+
)
|
|
159
|
+
if not result.is_sentence_end(sentence):
|
|
160
|
+
interim_event = stt.SpeechEvent(
|
|
161
|
+
type=stt.SpeechEventType.INTERIM_TRANSCRIPT,
|
|
162
|
+
alternatives=dg_alts,
|
|
163
|
+
)
|
|
164
|
+
self._stt._event_ch.send_nowait(interim_event)
|
|
165
|
+
logger.info("transcription start")
|
|
166
|
+
else:
|
|
167
|
+
final_event = stt.SpeechEvent(
|
|
168
|
+
type=stt.SpeechEventType.FINAL_TRANSCRIPT,
|
|
169
|
+
alternatives=dg_alts,
|
|
170
|
+
)
|
|
171
|
+
self._stt._event_ch.send_nowait(final_event)
|
|
172
|
+
logger.info(
|
|
173
|
+
"transcription end", extra={"text": final_event.alternatives[0].text}
|
|
174
|
+
)
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import AsyncIterable, Optional, Dict
|
|
4
|
+
import time
|
|
5
|
+
import aiohttp
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
from livekit.agents import tts, APIConnectOptions, DEFAULT_API_CONNECT_OPTIONS, utils
|
|
10
|
+
from osc_data.text_stream import TextStreamSentencizer
|
|
11
|
+
|
|
12
|
+
from .log import logger
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
STREAM_EOS = "EOS"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class TTSOptions:
|
|
20
|
+
api_key: str
|
|
21
|
+
model: str
|
|
22
|
+
# 语速,取值范围:0.5~2。
|
|
23
|
+
rate: float
|
|
24
|
+
# 音色
|
|
25
|
+
voice: str
|
|
26
|
+
# 合成音频的语速,取值范围:0.5~2。
|
|
27
|
+
speech_rate: int
|
|
28
|
+
# 合成音频的音量,取值范围:0~100。
|
|
29
|
+
volume: int
|
|
30
|
+
# 采样率,取值范围:8000, 16000, 22050, 24000, 44100, 48000
|
|
31
|
+
sample_rate: int
|
|
32
|
+
# 音调,取值范围:0.5~2。
|
|
33
|
+
pitch: float = 1.0
|
|
34
|
+
|
|
35
|
+
def get_ws_url(self) -> str:
|
|
36
|
+
return "wss://dashscope.aliyuncs.com/api-ws/v1/inference"
|
|
37
|
+
|
|
38
|
+
def get_ws_header(self) -> Dict[str, str]:
|
|
39
|
+
return {
|
|
40
|
+
"Authorization": f"bearer {self.api_key}",
|
|
41
|
+
"X-DashScope-DataInspection": "enable",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
def get_run_task_params(self) -> Dict[str, str]:
|
|
45
|
+
params = {
|
|
46
|
+
"header": {
|
|
47
|
+
"action": "run-task",
|
|
48
|
+
"task_id": utils.shortuuid(),
|
|
49
|
+
"streaming": "duplex",
|
|
50
|
+
},
|
|
51
|
+
"payload": {
|
|
52
|
+
"task_group": "audio",
|
|
53
|
+
"task": "tts",
|
|
54
|
+
"function": "SpeechSynthesizer",
|
|
55
|
+
"model": self.model,
|
|
56
|
+
"parameters": {
|
|
57
|
+
"text_type": "PlainText",
|
|
58
|
+
"voice": "longxiaochun_v2",
|
|
59
|
+
"format": "pcm",
|
|
60
|
+
"sample_rate": self.sample_rate,
|
|
61
|
+
"volume": self.volume,
|
|
62
|
+
"rate": self.rate,
|
|
63
|
+
"pitch": self.pitch,
|
|
64
|
+
},
|
|
65
|
+
"input": {},
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
return params
|
|
69
|
+
|
|
70
|
+
def get_continue_task_params(self, text: str) -> Dict[str, str]:
|
|
71
|
+
params = {
|
|
72
|
+
"header": {
|
|
73
|
+
"action": "continue-task",
|
|
74
|
+
"task_id": utils.shortuuid(),
|
|
75
|
+
"streaming": "duplex",
|
|
76
|
+
},
|
|
77
|
+
"payload": {
|
|
78
|
+
"input": {
|
|
79
|
+
"text": text,
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
return params
|
|
84
|
+
|
|
85
|
+
def get_finish_task_params(self) -> Dict[str, str]:
|
|
86
|
+
params = {
|
|
87
|
+
"header": {
|
|
88
|
+
"action": "finish-task",
|
|
89
|
+
"task_id": utils.shortuuid(),
|
|
90
|
+
"streaming": "duplex",
|
|
91
|
+
},
|
|
92
|
+
"payload": {"input": {}},
|
|
93
|
+
}
|
|
94
|
+
return params
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class TTS(tts.TTS):
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
*,
|
|
101
|
+
api_key: Optional[str] = None,
|
|
102
|
+
sample_rate: int = 24000,
|
|
103
|
+
voice: str = "longcheng",
|
|
104
|
+
model: str = "cosyvoice-v2",
|
|
105
|
+
speech_rate: int = 1,
|
|
106
|
+
volume: int = 100,
|
|
107
|
+
rate: float = 1.0,
|
|
108
|
+
pitch: float = 1.0,
|
|
109
|
+
http_session: aiohttp.ClientSession | None = None,
|
|
110
|
+
max_session_duration: float = 600,
|
|
111
|
+
) -> None:
|
|
112
|
+
super().__init__(
|
|
113
|
+
capabilities=tts.TTSCapabilities(streaming=True),
|
|
114
|
+
sample_rate=sample_rate,
|
|
115
|
+
num_channels=1,
|
|
116
|
+
)
|
|
117
|
+
api_key = api_key or os.environ.get("DASHSCOPE_API_KEY")
|
|
118
|
+
if not api_key:
|
|
119
|
+
raise ValueError("DASHSCOPE_API_KEY must be set")
|
|
120
|
+
self._session = http_session
|
|
121
|
+
self._opts = TTSOptions(
|
|
122
|
+
model=model,
|
|
123
|
+
api_key=api_key,
|
|
124
|
+
voice=voice,
|
|
125
|
+
speech_rate=speech_rate,
|
|
126
|
+
volume=volume,
|
|
127
|
+
sample_rate=sample_rate,
|
|
128
|
+
rate=rate,
|
|
129
|
+
pitch=pitch,
|
|
130
|
+
)
|
|
131
|
+
self._pool = utils.ConnectionPool[aiohttp.ClientWebSocketResponse](
|
|
132
|
+
connect_cb=self._connect_ws,
|
|
133
|
+
close_cb=self._close_ws,
|
|
134
|
+
max_session_duration=max_session_duration,
|
|
135
|
+
mark_refreshed_on_get=True,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def _ensure_session(self) -> aiohttp.ClientSession:
|
|
139
|
+
if self._session is None:
|
|
140
|
+
self._session = utils.http_context.http_session()
|
|
141
|
+
|
|
142
|
+
return self._session
|
|
143
|
+
|
|
144
|
+
async def _connect_ws(self, timeout: float) -> aiohttp.ClientWebSocketResponse:
|
|
145
|
+
session = self._ensure_session()
|
|
146
|
+
url = self._opts.get_ws_url()
|
|
147
|
+
headers = self._opts.get_ws_header()
|
|
148
|
+
return await asyncio.wait_for(
|
|
149
|
+
session.ws_connect(url, headers=headers),
|
|
150
|
+
timeout=timeout,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
async def _close_ws(self, ws: aiohttp.ClientWebSocketResponse):
|
|
154
|
+
await ws.close()
|
|
155
|
+
|
|
156
|
+
def synthesize(
|
|
157
|
+
self,
|
|
158
|
+
text: str,
|
|
159
|
+
) -> AsyncIterable[tts.SynthesizedAudio]:
|
|
160
|
+
raise NotImplementedError
|
|
161
|
+
|
|
162
|
+
def stream(
|
|
163
|
+
self, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
|
|
164
|
+
) -> "SynthesizeStream":
|
|
165
|
+
return SynthesizeStream(tts=self, opts=self._opts, conn_options=conn_options)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class SynthesizeStream(tts.SynthesizeStream):
|
|
169
|
+
def __init__(
|
|
170
|
+
self,
|
|
171
|
+
*,
|
|
172
|
+
tts: TTS,
|
|
173
|
+
opts: TTSOptions,
|
|
174
|
+
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
|
|
175
|
+
):
|
|
176
|
+
super().__init__(tts=tts, conn_options=conn_options)
|
|
177
|
+
self._opts = opts
|
|
178
|
+
|
|
179
|
+
async def _run(self, emitter: tts.AudioEmitter) -> None:
|
|
180
|
+
request_id = utils.shortuuid()
|
|
181
|
+
emitter.initialize(
|
|
182
|
+
request_id=request_id,
|
|
183
|
+
sample_rate=self._opts.sample_rate,
|
|
184
|
+
mime_type="audio/pcm",
|
|
185
|
+
stream=True,
|
|
186
|
+
num_channels=1,
|
|
187
|
+
frame_size_ms=200,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
async def _send_task(sentence: str, ws: aiohttp.ClientWebSocketResponse):
|
|
191
|
+
run_task_params = self._opts.get_run_task_params()
|
|
192
|
+
await ws.send_json(run_task_params)
|
|
193
|
+
continue_task_params = self._opts.get_continue_task_params(text=sentence)
|
|
194
|
+
await ws.send_json(continue_task_params)
|
|
195
|
+
finish_task_params = self._opts.get_finish_task_params()
|
|
196
|
+
await ws.send_json(finish_task_params)
|
|
197
|
+
|
|
198
|
+
async def _recv_task(ws: aiohttp.ClientWebSocketResponse):
|
|
199
|
+
is_first_response = True
|
|
200
|
+
start_time = time.perf_counter()
|
|
201
|
+
while True:
|
|
202
|
+
try:
|
|
203
|
+
msg = await ws.receive()
|
|
204
|
+
except Exception as e:
|
|
205
|
+
logger.warning(f"Error while receiving bytes: {e}")
|
|
206
|
+
break
|
|
207
|
+
if msg.type == aiohttp.WSMsgType.BINARY:
|
|
208
|
+
if is_first_response:
|
|
209
|
+
elapsed_time = time.perf_counter() - start_time
|
|
210
|
+
logger.info(
|
|
211
|
+
"tts first response",
|
|
212
|
+
extra={"spent": round(elapsed_time, 4)},
|
|
213
|
+
)
|
|
214
|
+
is_first_response = False
|
|
215
|
+
emitter.push(data=msg.data)
|
|
216
|
+
elif msg.type == aiohttp.WSMsgType.TEXT:
|
|
217
|
+
msg_json = json.loads(msg.data)
|
|
218
|
+
if "header" in msg_json:
|
|
219
|
+
header = msg_json["header"]
|
|
220
|
+
if "event" in header:
|
|
221
|
+
event = header["event"]
|
|
222
|
+
|
|
223
|
+
if event == "task-finished":
|
|
224
|
+
break
|
|
225
|
+
|
|
226
|
+
if event == "task-failed":
|
|
227
|
+
error_msg = msg_json.get("error_message", "未知错误")
|
|
228
|
+
logger.error(f"任务失败: {error_msg}")
|
|
229
|
+
|
|
230
|
+
splitter = TextStreamSentencizer()
|
|
231
|
+
is_first_sentence = True
|
|
232
|
+
start_time = time.perf_counter()
|
|
233
|
+
async for token in self._input_ch:
|
|
234
|
+
if isinstance(token, self._FlushSentinel):
|
|
235
|
+
sentences = splitter.flush()
|
|
236
|
+
else:
|
|
237
|
+
sentences = splitter.push(text=token)
|
|
238
|
+
for sentence in sentences:
|
|
239
|
+
if is_first_sentence:
|
|
240
|
+
first_sentence_spend = time.perf_counter() - start_time
|
|
241
|
+
logger.info(
|
|
242
|
+
"llm first sentence",
|
|
243
|
+
extra={"spent": str(first_sentence_spend)},
|
|
244
|
+
)
|
|
245
|
+
is_first_sentence = False
|
|
246
|
+
logger.info("tts start", extra={"sentence": sentence})
|
|
247
|
+
emitter.start_segment(segment_id=utils.shortuuid())
|
|
248
|
+
async with self._tts._pool.connection(
|
|
249
|
+
timeout=self._conn_options.timeout
|
|
250
|
+
) as ws:
|
|
251
|
+
assert not ws.closed, "WebSocket connection is closed"
|
|
252
|
+
tasks = [
|
|
253
|
+
asyncio.create_task(_send_task(sentence=sentence, ws=ws)),
|
|
254
|
+
asyncio.create_task(_recv_task(ws=ws)),
|
|
255
|
+
]
|
|
256
|
+
await asyncio.gather(*tasks)
|
|
257
|
+
emitter.end_segment()
|
|
258
|
+
logger.info("tts end", extra={"sentence": sentence})
|
|
259
|
+
self._pushed_text = self._pushed_text.replace(sentence, "")
|
|
260
|
+
await utils.aio.gracefully_cancel(*tasks)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import os
|
|
5
|
+
from collections import OrderedDict
|
|
6
|
+
from collections.abc import Awaitable
|
|
7
|
+
from typing import Any, Callable, Union
|
|
8
|
+
|
|
9
|
+
from openai.types.chat import (
|
|
10
|
+
ChatCompletionContentPartParam,
|
|
11
|
+
ChatCompletionMessageParam,
|
|
12
|
+
ChatCompletionToolParam,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from livekit.agents import llm
|
|
16
|
+
|
|
17
|
+
AsyncAzureADTokenProvider = Callable[[], Union[str, Awaitable[str]]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_base_url(base_url: str | None) -> str:
|
|
21
|
+
if not base_url:
|
|
22
|
+
base_url = os.getenv(
|
|
23
|
+
"ZHIPU_LLM_BASE_URL", "https://open.bigmodel.cn/api/paas/v4/"
|
|
24
|
+
)
|
|
25
|
+
return base_url
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def to_fnc_ctx(fnc_ctx: list[llm.FunctionTool]) -> list[ChatCompletionToolParam]:
|
|
29
|
+
return [llm.utils.build_strict_openai_schema(fnc) for fnc in fnc_ctx]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def to_chat_ctx(
|
|
33
|
+
chat_ctx: llm.ChatContext, cache_key: Any
|
|
34
|
+
) -> list[ChatCompletionMessageParam]:
|
|
35
|
+
# group the message and function_calls
|
|
36
|
+
item_groups: dict[str, list[llm.ChatItem]] = OrderedDict()
|
|
37
|
+
for item in chat_ctx.items:
|
|
38
|
+
if (
|
|
39
|
+
item.type == "message" and item.role == "assistant"
|
|
40
|
+
) or item.type == "function_call":
|
|
41
|
+
group_id = item.id.split("/")[0]
|
|
42
|
+
if group_id not in item_groups:
|
|
43
|
+
item_groups[group_id] = []
|
|
44
|
+
item_groups[group_id].append(item)
|
|
45
|
+
else:
|
|
46
|
+
item_groups[item.id] = [item]
|
|
47
|
+
|
|
48
|
+
return [_group_to_chat_item(items, cache_key) for items in item_groups.values()]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _group_to_chat_item(
|
|
52
|
+
items: list[llm.ChatItem], cache_key: Any
|
|
53
|
+
) -> ChatCompletionMessageParam:
|
|
54
|
+
if len(items) == 1:
|
|
55
|
+
return _to_chat_item(items[0], cache_key)
|
|
56
|
+
else:
|
|
57
|
+
msg = {"role": "assistant", "tool_calls": []}
|
|
58
|
+
for item in items:
|
|
59
|
+
if item.type == "message":
|
|
60
|
+
assert item.role == "assistant", (
|
|
61
|
+
"only assistant messages can be grouped"
|
|
62
|
+
)
|
|
63
|
+
assert "content" not in msg, (
|
|
64
|
+
"only one assistant message is allowed in a group"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
msg.update(_to_chat_item(item, cache_key))
|
|
68
|
+
elif item.type == "function_call":
|
|
69
|
+
msg["tool_calls"].append(
|
|
70
|
+
{
|
|
71
|
+
"id": item.call_id,
|
|
72
|
+
"type": "function",
|
|
73
|
+
"function": {"name": item.name, "arguments": item.arguments},
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
return msg
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _to_chat_item(msg: llm.ChatItem, cache_key: Any) -> ChatCompletionMessageParam:
|
|
80
|
+
if msg.type == "message":
|
|
81
|
+
list_content: list[ChatCompletionContentPartParam] = []
|
|
82
|
+
text_content = ""
|
|
83
|
+
for content in msg.content:
|
|
84
|
+
if isinstance(content, str):
|
|
85
|
+
if text_content:
|
|
86
|
+
text_content += "\n"
|
|
87
|
+
text_content += content
|
|
88
|
+
elif isinstance(content, llm.ImageContent):
|
|
89
|
+
list_content.append(_to_image_content(content, cache_key))
|
|
90
|
+
|
|
91
|
+
if not list_content:
|
|
92
|
+
# certain providers require text-only content in a string vs a list.
|
|
93
|
+
# for max-compatibility, we will combine all text content into a single string.
|
|
94
|
+
return {
|
|
95
|
+
"role": msg.role, # type: ignore
|
|
96
|
+
"content": text_content,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if text_content:
|
|
100
|
+
list_content.append({"type": "text", "text": text_content})
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
"role": msg.role, # type: ignore
|
|
104
|
+
"content": list_content,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
elif msg.type == "function_call":
|
|
108
|
+
return {
|
|
109
|
+
"role": "assistant",
|
|
110
|
+
"tool_calls": [
|
|
111
|
+
{
|
|
112
|
+
"id": msg.call_id,
|
|
113
|
+
"type": "function",
|
|
114
|
+
"function": {
|
|
115
|
+
"name": msg.name,
|
|
116
|
+
"arguments": msg.arguments,
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
],
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
elif msg.type == "function_call_output":
|
|
123
|
+
return {
|
|
124
|
+
"role": "tool",
|
|
125
|
+
"tool_call_id": msg.call_id,
|
|
126
|
+
"content": msg.output,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _to_image_content(
|
|
131
|
+
image: llm.ImageContent, cache_key: Any
|
|
132
|
+
) -> ChatCompletionContentPartParam:
|
|
133
|
+
img = llm.utils.serialize_image(image)
|
|
134
|
+
if img.external_url:
|
|
135
|
+
return {
|
|
136
|
+
"type": "image_url",
|
|
137
|
+
"image_url": {
|
|
138
|
+
"url": img.external_url,
|
|
139
|
+
"detail": img.inference_detail,
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
if cache_key not in image._cache:
|
|
143
|
+
image._cache[cache_key] = img.data_bytes
|
|
144
|
+
b64_data = base64.b64encode(image._cache[cache_key]).decode("utf-8")
|
|
145
|
+
return {
|
|
146
|
+
"type": "image_url",
|
|
147
|
+
"image_url": {
|
|
148
|
+
"url": f"data:{img.mime_type};base64,{b64_data}",
|
|
149
|
+
"detail": img.inference_detail,
|
|
150
|
+
},
|
|
151
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.1.0.post4"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "livekit-plugins-aliyun"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "LiveKit Agent Plugins for Alibaba Cloud"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "wangmengdi", email = "790990241@qq.com" }
|
|
8
|
+
]
|
|
9
|
+
keywords = ["webrtc", "realtime", "audio", "video", "livekit"]
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"livekit-agents~=1.1.0",
|
|
13
|
+
"dashscope",
|
|
14
|
+
"osc-data"
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: Apache Software License",
|
|
20
|
+
"Topic :: Multimedia :: Sound/Audio",
|
|
21
|
+
"Topic :: Multimedia :: Video",
|
|
22
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.9",
|
|
25
|
+
"Programming Language :: Python :: 3.10",
|
|
26
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["hatchling"]
|
|
33
|
+
build-backend = "hatchling.build"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.version]
|
|
36
|
+
path = "livekit/plugins/aliyun/version.py"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["livekit"]
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.sdist]
|
|
42
|
+
include = ["/livekit"]
|