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.
@@ -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
+ )
@@ -0,0 +1,3 @@
1
+ from logging import getLogger
2
+
3
+ logger = getLogger("livekit.plugins.aliyun")
@@ -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"]