aichat-sdk 0.1.0__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.
- aichat_sdk-0.1.0/.gitignore +27 -0
- aichat_sdk-0.1.0/PKG-INFO +83 -0
- aichat_sdk-0.1.0/README.md +48 -0
- aichat_sdk-0.1.0/aichat_sdk/__init__.py +423 -0
- aichat_sdk-0.1.0/aichat_sdk/asr/asr_client.py +206 -0
- aichat_sdk-0.1.0/aichat_sdk/config.py +12 -0
- aichat_sdk-0.1.0/aichat_sdk/llm/__init__.py +156 -0
- aichat_sdk-0.1.0/aichat_sdk/llm/mcp/__init__.py +317 -0
- aichat_sdk-0.1.0/aichat_sdk/llm/mcp/external.py +156 -0
- aichat_sdk-0.1.0/aichat_sdk/llm/mcp/mcp_end_session.py +10 -0
- aichat_sdk-0.1.0/aichat_sdk/llm/mcp/mcp_exec.py +32 -0
- aichat_sdk-0.1.0/aichat_sdk/llm/mcp/mcp_read_skill.py +44 -0
- aichat_sdk-0.1.0/aichat_sdk/llm/mcp/server.py +8 -0
- aichat_sdk-0.1.0/aichat_sdk/llm/openai_client.py +253 -0
- aichat_sdk-0.1.0/aichat_sdk/llm/skills.py +63 -0
- aichat_sdk-0.1.0/aichat_sdk/llm/system_prompt/__init__.py +49 -0
- aichat_sdk-0.1.0/aichat_sdk/llm/system_prompt/system.jinja2 +67 -0
- aichat_sdk-0.1.0/aichat_sdk/llm/type_enum.py +21 -0
- aichat_sdk-0.1.0/aichat_sdk/llm/utils/__init__.py +0 -0
- aichat_sdk-0.1.0/aichat_sdk/llm/utils/calendar.py +182 -0
- aichat_sdk-0.1.0/aichat_sdk/mcp_tool/__init__.py +17 -0
- aichat_sdk-0.1.0/aichat_sdk/mcp_tool/__main__.py +17 -0
- aichat_sdk-0.1.0/aichat_sdk/mcp_tool/mcp_joke.py +50 -0
- aichat_sdk-0.1.0/aichat_sdk/mcp_tool/mcp_play_music.py +67 -0
- aichat_sdk-0.1.0/aichat_sdk/mcp_tool/mcp_weather.py +51 -0
- aichat_sdk-0.1.0/aichat_sdk/mcp_tool/mcp_web_search.py +162 -0
- aichat_sdk-0.1.0/aichat_sdk/mcp_tool/music//344/270/200/350/267/257/345/220/221/345/214/227.jsonl +72 -0
- aichat_sdk-0.1.0/aichat_sdk/mcp_tool/music//346/231/264/345/244/251.jsonl +126 -0
- aichat_sdk-0.1.0/aichat_sdk/mcp_tool/server.py +8 -0
- aichat_sdk-0.1.0/aichat_sdk/tts/bytedance/bidirection.py +218 -0
- aichat_sdk-0.1.0/aichat_sdk/tts/bytedance/protocols/__init__.py +41 -0
- aichat_sdk-0.1.0/aichat_sdk/tts/bytedance/protocols/protocols.py +526 -0
- aichat_sdk-0.1.0/aichat_sdk/tts/bytedancev2/bidirection.py +338 -0
- aichat_sdk-0.1.0/aichat_sdk/tts/bytedancev2/protocols.py +508 -0
- aichat_sdk-0.1.0/aichat_sdk/tts/bytedancev3/bidirection.py +373 -0
- aichat_sdk-0.1.0/aichat_sdk/tts/bytedancev3/protocols/__init__.py +41 -0
- aichat_sdk-0.1.0/aichat_sdk/tts/bytedancev3/protocols/protocols.py +527 -0
- aichat_sdk-0.1.0/aichat_sdk/tts/edge/__init__.py +0 -0
- aichat_sdk-0.1.0/aichat_sdk/tts/edge/bidirection.py +184 -0
- aichat_sdk-0.1.0/aichat_sdk/tts/edge/main.py +94 -0
- aichat_sdk-0.1.0/aichat_sdk/utils/event_emitter.py +81 -0
- aichat_sdk-0.1.0/aichat_sdk/utils/opus_codec.py +136 -0
- aichat_sdk-0.1.0/pyproject.toml +55 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
build
|
|
2
|
+
dist
|
|
3
|
+
*.egg
|
|
4
|
+
*.egg-info
|
|
5
|
+
.DS_Store
|
|
6
|
+
__pycache__/
|
|
7
|
+
.idea/
|
|
8
|
+
|
|
9
|
+
# uv
|
|
10
|
+
.venv/
|
|
11
|
+
|
|
12
|
+
# pytest and coverage
|
|
13
|
+
.pytest_cache/
|
|
14
|
+
.coverage
|
|
15
|
+
htmlcov/
|
|
16
|
+
|
|
17
|
+
# mypy
|
|
18
|
+
.mypy_cache/
|
|
19
|
+
|
|
20
|
+
.env
|
|
21
|
+
*.log
|
|
22
|
+
*.pt
|
|
23
|
+
|
|
24
|
+
aichat_sdk/asr/audio_*
|
|
25
|
+
|
|
26
|
+
examples/web/config.json
|
|
27
|
+
examples/web/sessions/
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aichat_sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Chat SDK: LLM streaming, MCP tools, and ByteDance bidirectional TTS.
|
|
5
|
+
Project-URL: Homepage, https://gitee.com/fa0/aichat_sdk
|
|
6
|
+
Project-URL: Repository, https://gitee.com/fa0/aichat_sdk
|
|
7
|
+
Author: dairoot
|
|
8
|
+
Keywords: chat,openai,sdk,tts
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: av>=12.0
|
|
20
|
+
Requires-Dist: edge-tts>=7.0
|
|
21
|
+
Requires-Dist: fastmcp>=3.4.7
|
|
22
|
+
Requires-Dist: jinja2>=3.1.0
|
|
23
|
+
Requires-Dist: lunardate==0.2.2
|
|
24
|
+
Requires-Dist: numpy>=1.24
|
|
25
|
+
Requires-Dist: openai>=1.40.0
|
|
26
|
+
Requires-Dist: pydantic>=2.0
|
|
27
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
28
|
+
Requires-Dist: python-socks>=3.0.0
|
|
29
|
+
Requires-Dist: pyyaml>=6.0
|
|
30
|
+
Requires-Dist: sounddevice>=0.5.6
|
|
31
|
+
Requires-Dist: websockets>=12.0
|
|
32
|
+
Provides-Extra: dev
|
|
33
|
+
Requires-Dist: types-pyyaml>=6.0.12; extra == 'dev'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# aichat_sdk
|
|
37
|
+
|
|
38
|
+
一个语音对话 SDK。用户对着麦克风说话,SDK 负责听懂(语音识别)、想清楚(大模型 + 工具)、说出来(语音合成),宿主程序只需要把声音收进来、把声音放出去。
|
|
39
|
+
|
|
40
|
+
## 它能做什么
|
|
41
|
+
|
|
42
|
+
- **像人一样聊天**:回复是口语、短句,不念标题、不念符号,适合直接朗读;用户随时插话就能打断
|
|
43
|
+
- **会查东西**:天气、笑话、联网搜索(微博、搜狗,不用配 key),还能按需接入外部 MCP 工具
|
|
44
|
+
- **会用技能**:读取本机 `~/.agents/skills` 下的技能说明,按里面的指引一步步执行命令,比如查飞书群聊、发消息、看日程
|
|
45
|
+
- **会放音乐**:本地曲库点歌,边放边推歌词
|
|
46
|
+
- **知道时间和地点**:公历、农历、临近节日、用户所在城市,都会告诉模型
|
|
47
|
+
- **自己结束对话**:聊完了或者空闲太久,会说句再见然后关会话
|
|
48
|
+
|
|
49
|
+
## 它是怎么工作的
|
|
50
|
+
|
|
51
|
+
整条链路是一根事件驱动的流水线:
|
|
52
|
+
|
|
53
|
+
1. **听**:麦克风的声音通过 WebSocket 送到独立的 ASR 服务(单独的 `asr_server` 仓库,识别、断句、声纹都在那边),识别结果回到 SDK
|
|
54
|
+
2. **想**:识别出的文字交给大模型。模型可以直接回答,也可以先调工具、读技能、执行命令,拿到结果再回答;整个过程流式进行,第一个字出来就开始往下传
|
|
55
|
+
3. **说**:模型每吐出一点文字就喂给语音合成,合成出的音频编成 Opus 包排进队列
|
|
56
|
+
4. **播**:宿主从队列里按顺序取消息——识别结果、工具调用、每句话的开始和结束、音频包、歌词、结束信号——自己解码播放
|
|
57
|
+
|
|
58
|
+
有一条必须遵守的约定:**收到音频就把麦克风静音,收到「音频播完」再恢复拾音**,否则机器会听见自己说话。
|
|
59
|
+
|
|
60
|
+
## 大模型这一层
|
|
61
|
+
|
|
62
|
+
- 支持任何 OpenAI 兼容的接口,实际用过 DeepSeek 和通义千问
|
|
63
|
+
- 系统提示词是模板生成的,把角色人设、当前日期、用户位置、可用技能清单一起注入;提示词专门为语音场景写,要求模型说人话、说短话
|
|
64
|
+
- **思考模式按需开关**:用户刚说完话的第一次调用不开思考,保证回得快;一旦调过工具,后面的调用就打开思考,让推理过程走单独的通道、不会被念出来(`AgentInfo.tool_thinking` 可关掉,网页控制台上也有对应勾选框)。这么做是因为某些模型关掉思考后会把「让我先看看」这类内心独白直接写进回复,提示词管不住
|
|
65
|
+
- 推理内容会保存在对话记录里,web 页面上折叠显示,方便排查模型为什么这么答
|
|
66
|
+
|
|
67
|
+
## 语音合成
|
|
68
|
+
|
|
69
|
+
- **字节跳动**(默认):服务端双向流式,逐字喂进去就出声,延迟最低
|
|
70
|
+
- **微软 Edge**:免 key,按标点切成小段并发请求,段短所以首包也快
|
|
71
|
+
|
|
72
|
+
两种引擎输出格式一致,切换只需要改一个参数。
|
|
73
|
+
|
|
74
|
+
## 怎么试
|
|
75
|
+
|
|
76
|
+
- 配好 `.env`:大模型的地址、模型名和 key;用字节 TTS 的话再加它的 App ID 和 Key;ASR 服务地址不改就用默认的本机端口
|
|
77
|
+
- 先把 `asr_server` 跑起来
|
|
78
|
+
- 有麦克风和扬声器就运行 `tests/test_run_v2.py` 直接对话;没有麦克风就运行 `tests/test_run.py`,它在代码里塞了两句话进去
|
|
79
|
+
- 想边聊边改配置,运行 `examples/web/main.py`,浏览器打开本机 8080 端口:可以换模型、换音色、改人设、开关工具、配外部 MCP,保存后立即生效;页面上还能实时看到每一轮对话、工具调用和模型的思考内容,每轮聊完会自动存一份完整记录
|
|
80
|
+
|
|
81
|
+
## 给开发者
|
|
82
|
+
|
|
83
|
+
要改代码,先看 `AGENTS.md`:队列和事件的实现细节、MCP 工具怎么注册注销、TTS 会话的生命周期限制、各种踩过的坑都记在那里。
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# aichat_sdk
|
|
2
|
+
|
|
3
|
+
一个语音对话 SDK。用户对着麦克风说话,SDK 负责听懂(语音识别)、想清楚(大模型 + 工具)、说出来(语音合成),宿主程序只需要把声音收进来、把声音放出去。
|
|
4
|
+
|
|
5
|
+
## 它能做什么
|
|
6
|
+
|
|
7
|
+
- **像人一样聊天**:回复是口语、短句,不念标题、不念符号,适合直接朗读;用户随时插话就能打断
|
|
8
|
+
- **会查东西**:天气、笑话、联网搜索(微博、搜狗,不用配 key),还能按需接入外部 MCP 工具
|
|
9
|
+
- **会用技能**:读取本机 `~/.agents/skills` 下的技能说明,按里面的指引一步步执行命令,比如查飞书群聊、发消息、看日程
|
|
10
|
+
- **会放音乐**:本地曲库点歌,边放边推歌词
|
|
11
|
+
- **知道时间和地点**:公历、农历、临近节日、用户所在城市,都会告诉模型
|
|
12
|
+
- **自己结束对话**:聊完了或者空闲太久,会说句再见然后关会话
|
|
13
|
+
|
|
14
|
+
## 它是怎么工作的
|
|
15
|
+
|
|
16
|
+
整条链路是一根事件驱动的流水线:
|
|
17
|
+
|
|
18
|
+
1. **听**:麦克风的声音通过 WebSocket 送到独立的 ASR 服务(单独的 `asr_server` 仓库,识别、断句、声纹都在那边),识别结果回到 SDK
|
|
19
|
+
2. **想**:识别出的文字交给大模型。模型可以直接回答,也可以先调工具、读技能、执行命令,拿到结果再回答;整个过程流式进行,第一个字出来就开始往下传
|
|
20
|
+
3. **说**:模型每吐出一点文字就喂给语音合成,合成出的音频编成 Opus 包排进队列
|
|
21
|
+
4. **播**:宿主从队列里按顺序取消息——识别结果、工具调用、每句话的开始和结束、音频包、歌词、结束信号——自己解码播放
|
|
22
|
+
|
|
23
|
+
有一条必须遵守的约定:**收到音频就把麦克风静音,收到「音频播完」再恢复拾音**,否则机器会听见自己说话。
|
|
24
|
+
|
|
25
|
+
## 大模型这一层
|
|
26
|
+
|
|
27
|
+
- 支持任何 OpenAI 兼容的接口,实际用过 DeepSeek 和通义千问
|
|
28
|
+
- 系统提示词是模板生成的,把角色人设、当前日期、用户位置、可用技能清单一起注入;提示词专门为语音场景写,要求模型说人话、说短话
|
|
29
|
+
- **思考模式按需开关**:用户刚说完话的第一次调用不开思考,保证回得快;一旦调过工具,后面的调用就打开思考,让推理过程走单独的通道、不会被念出来(`AgentInfo.tool_thinking` 可关掉,网页控制台上也有对应勾选框)。这么做是因为某些模型关掉思考后会把「让我先看看」这类内心独白直接写进回复,提示词管不住
|
|
30
|
+
- 推理内容会保存在对话记录里,web 页面上折叠显示,方便排查模型为什么这么答
|
|
31
|
+
|
|
32
|
+
## 语音合成
|
|
33
|
+
|
|
34
|
+
- **字节跳动**(默认):服务端双向流式,逐字喂进去就出声,延迟最低
|
|
35
|
+
- **微软 Edge**:免 key,按标点切成小段并发请求,段短所以首包也快
|
|
36
|
+
|
|
37
|
+
两种引擎输出格式一致,切换只需要改一个参数。
|
|
38
|
+
|
|
39
|
+
## 怎么试
|
|
40
|
+
|
|
41
|
+
- 配好 `.env`:大模型的地址、模型名和 key;用字节 TTS 的话再加它的 App ID 和 Key;ASR 服务地址不改就用默认的本机端口
|
|
42
|
+
- 先把 `asr_server` 跑起来
|
|
43
|
+
- 有麦克风和扬声器就运行 `tests/test_run_v2.py` 直接对话;没有麦克风就运行 `tests/test_run.py`,它在代码里塞了两句话进去
|
|
44
|
+
- 想边聊边改配置,运行 `examples/web/main.py`,浏览器打开本机 8080 端口:可以换模型、换音色、改人设、开关工具、配外部 MCP,保存后立即生效;页面上还能实时看到每一轮对话、工具调用和模型的思考内容,每轮聊完会自动存一份完整记录
|
|
45
|
+
|
|
46
|
+
## 给开发者
|
|
47
|
+
|
|
48
|
+
要改代码,先看 `AGENTS.md`:队列和事件的实现细节、MCP 工具怎么注册注销、TTS 会话的生命周期限制、各种踩过的坑都记在那里。
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from aichat_sdk.asr.asr_client import AsrWebSocketClient
|
|
7
|
+
from aichat_sdk.config import ASR_SERVER_WS_URL
|
|
8
|
+
from aichat_sdk.llm import LlmAgent
|
|
9
|
+
from aichat_sdk.llm.mcp import get_default_chat_tools
|
|
10
|
+
from aichat_sdk.llm.type_enum import AgentInfo, DeviceInfo
|
|
11
|
+
from aichat_sdk.utils.opus_codec import (
|
|
12
|
+
OpusStreamDecoder,
|
|
13
|
+
OpusStreamEncoder,
|
|
14
|
+
PcmStreamResampler,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
IDLE_TIMEOUT_SECONDS = 60
|
|
23
|
+
IDLE_FAREWELL_PROMPT = (
|
|
24
|
+
"用户已长时间没有说话,本次会话即将自动结束。"
|
|
25
|
+
"请直接输出一两句简短自然的道别话,可以结合刚才聊到的内容。"
|
|
26
|
+
"只输出纯文本,不要调用任何工具,也不要向用户提问或期待回复。"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_tts_client(tts_engine: str = "bytedance"):
|
|
31
|
+
engine = tts_engine.lower()
|
|
32
|
+
if engine in {"edge", "edge-tts"}:
|
|
33
|
+
from aichat_sdk.tts.edge.bidirection import EdgeTTSClient
|
|
34
|
+
|
|
35
|
+
return EdgeTTSClient()
|
|
36
|
+
if engine in {"bytedance", "v2", "bytedancev2"}:
|
|
37
|
+
from aichat_sdk.tts.bytedancev2.bidirection import BytedanceTTSClient
|
|
38
|
+
|
|
39
|
+
return BytedanceTTSClient(voice_type="zh_female_wanwanxiaohe_moon_bigtts")
|
|
40
|
+
if engine in {"v3", "bytedancev3"}:
|
|
41
|
+
from aichat_sdk.tts.bytedancev3.bidirection import BytedanceTTSClient
|
|
42
|
+
|
|
43
|
+
return BytedanceTTSClient(voice_type="zh_female_vv_uranus_bigtts")
|
|
44
|
+
raise ValueError("未知 tts_engine,支持: bytedance, v2, v3, edge")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ChatBot:
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
tts_engine: str = "bytedance",
|
|
51
|
+
input_sample_rate: int = 24000, # Opus 编码采样率(限 8k/12k/16k/24k/48k),TTS 源 PCM 采样率不同时会自动重采样;
|
|
52
|
+
output_sample_rate: int = 24000, # 宿主解码后拿到的 PCM 采样率(任意),解码器用 create_opus_decoder() 创建。
|
|
53
|
+
asr_server_url: str = ASR_SERVER_WS_URL, # ASR 服务地址
|
|
54
|
+
send_idle_farewell: bool = True, # 空闲超时后是否先发送告别语,为 False 时直接关闭会话。
|
|
55
|
+
user_info: DeviceInfo = DeviceInfo(timezone=8, city="北京"), # 用户信息
|
|
56
|
+
agent_info: AgentInfo = AgentInfo(), # 角色信息
|
|
57
|
+
) -> None:
|
|
58
|
+
"""input_sample_rate:Opus 编码采样率(限 8k/12k/16k/24k/48k),
|
|
59
|
+
TTS 源 PCM 采样率不同时会自动重采样;
|
|
60
|
+
output_sample_rate:宿主解码后拿到的 PCM 采样率(任意),解码器用 create_opus_decoder() 创建。
|
|
61
|
+
send_idle_farewell:空闲超时后是否先发送告别语,为 False 时直接关闭会话。
|
|
62
|
+
"""
|
|
63
|
+
self.tts = get_tts_client(tts_engine)
|
|
64
|
+
self.asr = AsrWebSocketClient(asr_server_url)
|
|
65
|
+
|
|
66
|
+
self.output_sample_rate = output_sample_rate
|
|
67
|
+
self._send_idle_farewell = send_idle_farewell
|
|
68
|
+
self.llm = LlmAgent(user_info=user_info, agent_info=agent_info)
|
|
69
|
+
self._queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
70
|
+
self._input_sample_rate = input_sample_rate
|
|
71
|
+
self._opus_encoder = OpusStreamEncoder(input_sample_rate)
|
|
72
|
+
self._pcm_resampler: PcmStreamResampler | None = None
|
|
73
|
+
self._audio_text = ""
|
|
74
|
+
self._audio_terminal_sent = True
|
|
75
|
+
self._tts_session_started = False
|
|
76
|
+
self._chat_task: asyncio.Task | None = None
|
|
77
|
+
self._session_lock = asyncio.Lock()
|
|
78
|
+
self._asr_dispatch_tasks: set[asyncio.Task] = set()
|
|
79
|
+
self._pending_mcp_call: tuple[str, dict[str, Any]] | None = None
|
|
80
|
+
# 宿主从队列取走 audio_finish 时置位,等"真正播完"用
|
|
81
|
+
self._audio_finish_consumed = asyncio.Event()
|
|
82
|
+
|
|
83
|
+
self._is_session_closed = False
|
|
84
|
+
self._idle_task: asyncio.Task | None = None
|
|
85
|
+
|
|
86
|
+
def create_opus_decoder(self) -> OpusStreamDecoder:
|
|
87
|
+
"""给宿主建配套解码器,输出 output_sample_rate 的 PCM,按同样的采样率播放。"""
|
|
88
|
+
return OpusStreamDecoder(self.output_sample_rate)
|
|
89
|
+
|
|
90
|
+
def audio_callback(self, indata, frames, time, status) -> None:
|
|
91
|
+
"""把宿主采集的 16 kHz 单声道 Float32 PCM 发往 ASR 服务。"""
|
|
92
|
+
self.asr.audio_callback(indata, frames, time, status)
|
|
93
|
+
|
|
94
|
+
def _start_idle_timer(self):
|
|
95
|
+
self._cancel_idle_timer()
|
|
96
|
+
if not self._is_session_closed:
|
|
97
|
+
self._idle_task = asyncio.create_task(self._idle_farewell())
|
|
98
|
+
|
|
99
|
+
def _cancel_idle_timer(self):
|
|
100
|
+
if self._idle_task and not self._idle_task.done():
|
|
101
|
+
self._idle_task.cancel()
|
|
102
|
+
self._idle_task = None
|
|
103
|
+
|
|
104
|
+
async def _idle_farewell(self):
|
|
105
|
+
"""超过 IDLE_TIMEOUT_SECONDS 用户无回复,按配置告别或直接结束会话。"""
|
|
106
|
+
await asyncio.sleep(IDLE_TIMEOUT_SECONDS)
|
|
107
|
+
# 先清引用再 send_text,否则 send_text 里的 abort 会把当前任务自己 cancel 掉
|
|
108
|
+
self._idle_task = None
|
|
109
|
+
self._is_session_closed = True
|
|
110
|
+
|
|
111
|
+
if self._send_idle_farewell:
|
|
112
|
+
logger.info(f"[idle] 用户超过 {IDLE_TIMEOUT_SECONDS}s 未回复,推送告别")
|
|
113
|
+
await self.send_text("", response_instructions=IDLE_FAREWELL_PROMPT, role="user")
|
|
114
|
+
# await self.send_text(IDLE_FAREWELL_PROMPT, role="system")
|
|
115
|
+
|
|
116
|
+
else:
|
|
117
|
+
logger.info(f"[idle] 用户超过 {IDLE_TIMEOUT_SECONDS}s 未回复,直接关闭")
|
|
118
|
+
await self._queue.put({"type": "close"})
|
|
119
|
+
|
|
120
|
+
async def receive_item(self):
|
|
121
|
+
item = await self._queue.get()
|
|
122
|
+
if item.get("type") == "lyric":
|
|
123
|
+
logger.info(f"[music] lyric: {item['data']}")
|
|
124
|
+
# 空闲计时以宿主消费(≈播放)节奏为准:取到 audio_finish 即真正播完、开始计时;
|
|
125
|
+
# 音频是瞬间推满队列的(整首歌几秒推完),按推送侧计时会在播放途中误触发。
|
|
126
|
+
elif item.get("type") == "audio_finish":
|
|
127
|
+
self._start_idle_timer()
|
|
128
|
+
self._audio_finish_consumed.set()
|
|
129
|
+
elif item.get("type") == "audio":
|
|
130
|
+
self._cancel_idle_timer()
|
|
131
|
+
return item
|
|
132
|
+
|
|
133
|
+
async def reg_llm(self):
|
|
134
|
+
|
|
135
|
+
@self.llm.on("text")
|
|
136
|
+
async def handle_chat_text(text: str):
|
|
137
|
+
# 会话开着但 10s 没收到文本,服务端就把它作废:之后送的字不再出声,
|
|
138
|
+
# FinishSession 也可能不回(实测 9s 正常、10s 起必挂)。工具调用和
|
|
139
|
+
# LLM 首字慢都能轻松撑过 10s,所以等真的有字了再开会话。
|
|
140
|
+
if not self._tts_session_started:
|
|
141
|
+
self._tts_session_started = True
|
|
142
|
+
await self.tts.start_session()
|
|
143
|
+
await self.tts.send_char(text)
|
|
144
|
+
|
|
145
|
+
@self.llm.on("tool")
|
|
146
|
+
async def handle_chat_tool(payload: dict):
|
|
147
|
+
arguments_str = payload["arguments"]
|
|
148
|
+
if len(arguments_str) > 50:
|
|
149
|
+
arguments_str = arguments_str[:50] + "..."
|
|
150
|
+
logger.info(f"[tool] tool_call: {payload['name']} ({arguments_str})")
|
|
151
|
+
await self._queue.put({"type": "tool", "data": payload})
|
|
152
|
+
|
|
153
|
+
@self.llm.on("before_tool_exec")
|
|
154
|
+
async def handle_chat_before_tool_exec(text: str):
|
|
155
|
+
await self._wait_audio_played(text)
|
|
156
|
+
|
|
157
|
+
@self.llm.on("tool_results")
|
|
158
|
+
async def handle_chat_tool_results(payload: list[dict]):
|
|
159
|
+
payload_str = json.dumps(payload, ensure_ascii=False)
|
|
160
|
+
if len(payload_str) > 100:
|
|
161
|
+
payload_str = payload_str[:100] + "..."
|
|
162
|
+
logger.info(f"[tool] tool_results: {payload_str}")
|
|
163
|
+
await self._queue.put({"type": "tool_results", "data": payload})
|
|
164
|
+
|
|
165
|
+
if any(msg.get("name") == "end_session" for msg in payload):
|
|
166
|
+
self._is_session_closed = True
|
|
167
|
+
|
|
168
|
+
for msg in payload:
|
|
169
|
+
if msg["name"] == "play_music":
|
|
170
|
+
try:
|
|
171
|
+
result = json.loads(msg["content"])
|
|
172
|
+
mcp_name = result.get("mcp")
|
|
173
|
+
song = result.get("song")
|
|
174
|
+
if isinstance(mcp_name, str) and isinstance(song, str):
|
|
175
|
+
self._pending_mcp_call = (mcp_name, {"song": song})
|
|
176
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
177
|
+
pass # 结果里没有 path 说明选歌失败,无需播放
|
|
178
|
+
|
|
179
|
+
@self.llm.on("finish")
|
|
180
|
+
async def handle_chat_finish(text: str):
|
|
181
|
+
self._audio_text = text
|
|
182
|
+
await self.tts.finish_session()
|
|
183
|
+
# 正常实现会在 finish_session 内触发 audio_finish;这里作为协议
|
|
184
|
+
# 兜底,防止第三方 TTS 返回却忘记发终结事件。
|
|
185
|
+
await self._finish_audio_and_followups()
|
|
186
|
+
|
|
187
|
+
async def reg_tts(self):
|
|
188
|
+
|
|
189
|
+
@self.tts.on("audio")
|
|
190
|
+
async def handle_audio(pcm_data: bytes):
|
|
191
|
+
if self._pcm_resampler is not None:
|
|
192
|
+
pcm_data = self._pcm_resampler.resample(pcm_data)
|
|
193
|
+
for packet in self._opus_encoder.encode(pcm_data):
|
|
194
|
+
await self._queue.put({"type": "audio", "data": packet})
|
|
195
|
+
|
|
196
|
+
@self.tts.on("audio_finish")
|
|
197
|
+
async def handle_audio_finish():
|
|
198
|
+
await self._finish_audio_and_followups()
|
|
199
|
+
|
|
200
|
+
@self.tts.on("sentence_start")
|
|
201
|
+
async def handle_sentence_start(text: str):
|
|
202
|
+
logger.info(f"[tts] {text}")
|
|
203
|
+
await self._queue.put({"type": "sentence_start", "data": text})
|
|
204
|
+
|
|
205
|
+
@self.tts.on("sentence_end")
|
|
206
|
+
async def handle_sentence_end(text: str):
|
|
207
|
+
await self._queue.put({"type": "sentence_end", "data": text})
|
|
208
|
+
|
|
209
|
+
async def reg_asr(self):
|
|
210
|
+
@self.asr.on("stt")
|
|
211
|
+
async def handle_stt(payload: dict):
|
|
212
|
+
logger.info(f"[asr] {payload['content']}")
|
|
213
|
+
self.tts.set_last_context_text(payload["content"])
|
|
214
|
+
task = asyncio.create_task(self.send_text(payload["content"], audio_url=payload.get("audio_url")))
|
|
215
|
+
self._asr_dispatch_tasks.add(task)
|
|
216
|
+
task.add_done_callback(self._finish_asr_dispatch)
|
|
217
|
+
|
|
218
|
+
@self.asr.on("error")
|
|
219
|
+
async def handle_asr_error(payload: dict):
|
|
220
|
+
logger.error("[asr] server error: %s", payload)
|
|
221
|
+
|
|
222
|
+
def _finish_asr_dispatch(self, task: asyncio.Task) -> None:
|
|
223
|
+
self._asr_dispatch_tasks.discard(task)
|
|
224
|
+
if task.cancelled():
|
|
225
|
+
return
|
|
226
|
+
error = task.exception()
|
|
227
|
+
if error is not None:
|
|
228
|
+
logger.error(
|
|
229
|
+
"ASR result dispatch failed",
|
|
230
|
+
exc_info=(type(error), error, error.__traceback__),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
async def initialize(self):
|
|
234
|
+
await self.reg_llm()
|
|
235
|
+
await self.reg_tts()
|
|
236
|
+
await self.reg_asr()
|
|
237
|
+
try:
|
|
238
|
+
await self.asr.connect()
|
|
239
|
+
except Exception as e:
|
|
240
|
+
logger.error(f"ASR connect failed: {e}")
|
|
241
|
+
await self.asr.close()
|
|
242
|
+
exit(1)
|
|
243
|
+
try:
|
|
244
|
+
await self.tts.connect()
|
|
245
|
+
except Exception as e:
|
|
246
|
+
logger.error(f"TTS connect failed: {e}")
|
|
247
|
+
await self.tts.close()
|
|
248
|
+
exit(1)
|
|
249
|
+
# 工具列表是懒构建的,不预热的话第一次拉起外部 MCP server(stdio 子进程)
|
|
250
|
+
# 的耗时会算进第一轮回复;构建是阻塞调用,放线程里跑不挡事件循环。
|
|
251
|
+
await asyncio.to_thread(get_default_chat_tools)
|
|
252
|
+
source_sample_rate = getattr(
|
|
253
|
+
self.tts,
|
|
254
|
+
"sample_rate",
|
|
255
|
+
self._input_sample_rate,
|
|
256
|
+
)
|
|
257
|
+
if source_sample_rate != self._input_sample_rate:
|
|
258
|
+
self._pcm_resampler = PcmStreamResampler(
|
|
259
|
+
source_sample_rate,
|
|
260
|
+
self._input_sample_rate,
|
|
261
|
+
)
|
|
262
|
+
self._start_idle_timer()
|
|
263
|
+
|
|
264
|
+
async def close(self):
|
|
265
|
+
self._cancel_idle_timer()
|
|
266
|
+
for task in self._asr_dispatch_tasks:
|
|
267
|
+
task.cancel()
|
|
268
|
+
if self._asr_dispatch_tasks:
|
|
269
|
+
await asyncio.gather(
|
|
270
|
+
*self._asr_dispatch_tasks,
|
|
271
|
+
return_exceptions=True,
|
|
272
|
+
)
|
|
273
|
+
self._asr_dispatch_tasks.clear()
|
|
274
|
+
await self.abort()
|
|
275
|
+
await self.asr.close()
|
|
276
|
+
close = getattr(self.tts, "close", None) or getattr(
|
|
277
|
+
self.tts,
|
|
278
|
+
"disconnect",
|
|
279
|
+
None,
|
|
280
|
+
)
|
|
281
|
+
if close is not None:
|
|
282
|
+
await close()
|
|
283
|
+
|
|
284
|
+
async def _run_session_task(self, coroutine_factory) -> None:
|
|
285
|
+
async with self._session_lock:
|
|
286
|
+
await self._abort_locked()
|
|
287
|
+
task = asyncio.create_task(coroutine_factory())
|
|
288
|
+
self._chat_task = task
|
|
289
|
+
try:
|
|
290
|
+
await task
|
|
291
|
+
except asyncio.CancelledError:
|
|
292
|
+
# 被下一轮会话替换时,只有子任务被 cancel,当前包装任务本身没有
|
|
293
|
+
# cancellation request;调用方主动 cancel 时则清理会话并继续传播。
|
|
294
|
+
if asyncio.current_task().cancelling():
|
|
295
|
+
async with self._session_lock:
|
|
296
|
+
if self._chat_task is task:
|
|
297
|
+
await self._abort_locked(notify_host=True)
|
|
298
|
+
raise
|
|
299
|
+
finally:
|
|
300
|
+
async with self._session_lock:
|
|
301
|
+
if self._chat_task is task:
|
|
302
|
+
self._chat_task = None
|
|
303
|
+
|
|
304
|
+
async def send_text(self, user_text: str, role: str = "user", response_instructions: str = None, audio_url: str | None = None):
|
|
305
|
+
"""audio_url:这句话对应的用户录音地址(ASR 服务给的),只挂在 messages 上供宿主回放,不发给 LLM。"""
|
|
306
|
+
await self._run_session_task(lambda: self._run_chat(user_text, role, response_instructions, audio_url))
|
|
307
|
+
|
|
308
|
+
async def _run_chat(self, user_text: str, role, response_instructions, audio_url: str | None = None):
|
|
309
|
+
self._prepare_audio_session()
|
|
310
|
+
try:
|
|
311
|
+
await self._queue.put({"type": "stt", "data": user_text})
|
|
312
|
+
await self.llm.chat(user_text, role, response_instructions, audio_url=audio_url)
|
|
313
|
+
except asyncio.CancelledError:
|
|
314
|
+
raise
|
|
315
|
+
except Exception:
|
|
316
|
+
logger.exception("chat pipeline failed")
|
|
317
|
+
await self._fail_audio_session()
|
|
318
|
+
raise
|
|
319
|
+
|
|
320
|
+
async def send_tts_text(self, text: str):
|
|
321
|
+
await self._run_session_task(lambda: self._run_tts_text(text))
|
|
322
|
+
|
|
323
|
+
async def _run_tts_text(self, text: str) -> None:
|
|
324
|
+
self._prepare_audio_session()
|
|
325
|
+
try:
|
|
326
|
+
await self.tts.start_session()
|
|
327
|
+
await self.tts.send_char(text)
|
|
328
|
+
self._audio_text = text
|
|
329
|
+
await self.tts.finish_session()
|
|
330
|
+
await self._finish_audio_and_followups()
|
|
331
|
+
except asyncio.CancelledError:
|
|
332
|
+
raise
|
|
333
|
+
except Exception:
|
|
334
|
+
logger.exception("direct TTS pipeline failed")
|
|
335
|
+
await self._fail_audio_session()
|
|
336
|
+
raise
|
|
337
|
+
|
|
338
|
+
async def _wait_audio_played(self, text: str) -> None:
|
|
339
|
+
"""工具执行前等本轮已出声的文本真正播完。
|
|
340
|
+
|
|
341
|
+
先收掉 TTS 会话、推 audio_finish,再等宿主把它取走(音频是瞬间推满队列的,
|
|
342
|
+
只有宿主取到 audio_finish 才算播完)。之后按新一段音频处理:工具返回后的
|
|
343
|
+
回复重新开会话、结束时再推一条 audio_finish。一个字都没吐过就不用等,
|
|
344
|
+
也不能去收一个没开过的会话。
|
|
345
|
+
"""
|
|
346
|
+
if not self._tts_session_started:
|
|
347
|
+
return
|
|
348
|
+
self._audio_text = text
|
|
349
|
+
self._audio_finish_consumed.clear()
|
|
350
|
+
await self.tts.finish_session()
|
|
351
|
+
await self._finish_audio_and_followups()
|
|
352
|
+
await self._audio_finish_consumed.wait()
|
|
353
|
+
# 取走 audio_finish 会起空闲计时,但工具还在跑,不算用户没回复
|
|
354
|
+
self._cancel_idle_timer()
|
|
355
|
+
self._prepare_audio_session()
|
|
356
|
+
|
|
357
|
+
def _prepare_audio_session(self) -> None:
|
|
358
|
+
self._audio_text = ""
|
|
359
|
+
self._audio_terminal_sent = False
|
|
360
|
+
self._tts_session_started = False
|
|
361
|
+
|
|
362
|
+
async def _enqueue_audio_finish(self) -> bool:
|
|
363
|
+
if self._audio_terminal_sent:
|
|
364
|
+
return False
|
|
365
|
+
self._audio_terminal_sent = True
|
|
366
|
+
if self._pcm_resampler is not None:
|
|
367
|
+
pcm = self._pcm_resampler.flush()
|
|
368
|
+
for packet in self._opus_encoder.encode(pcm):
|
|
369
|
+
await self._queue.put({"type": "audio", "data": packet})
|
|
370
|
+
for packet in self._opus_encoder.flush():
|
|
371
|
+
await self._queue.put({"type": "audio", "data": packet})
|
|
372
|
+
await self._queue.put({"type": "audio_finish", "data": self._audio_text})
|
|
373
|
+
return True
|
|
374
|
+
|
|
375
|
+
async def _finish_audio_and_followups(self) -> None:
|
|
376
|
+
if not await self._enqueue_audio_finish():
|
|
377
|
+
return
|
|
378
|
+
if self._is_session_closed:
|
|
379
|
+
await self._queue.put({"type": "close"})
|
|
380
|
+
elif self._pending_mcp_call:
|
|
381
|
+
# 等本轮回复的语音播完再开始放歌,避免歌声盖住回复
|
|
382
|
+
name, args = self._pending_mcp_call
|
|
383
|
+
self._pending_mcp_call = None
|
|
384
|
+
async for data in self.llm.stream_mcp_tool(name, args):
|
|
385
|
+
await self._queue.put(data)
|
|
386
|
+
|
|
387
|
+
async def _fail_audio_session(self) -> None:
|
|
388
|
+
try:
|
|
389
|
+
await self.tts.abort()
|
|
390
|
+
except Exception:
|
|
391
|
+
logger.exception("failed to abort TTS after pipeline error")
|
|
392
|
+
await self._enqueue_audio_finish()
|
|
393
|
+
|
|
394
|
+
async def abort(self):
|
|
395
|
+
async with self._session_lock:
|
|
396
|
+
await self._abort_locked(notify_host=True)
|
|
397
|
+
|
|
398
|
+
async def _abort_locked(self, *, notify_host: bool = False) -> None:
|
|
399
|
+
self._cancel_idle_timer()
|
|
400
|
+
session_was_active = not self._audio_terminal_sent
|
|
401
|
+
task = self._chat_task
|
|
402
|
+
if task and task is not asyncio.current_task() and not task.done():
|
|
403
|
+
task.cancel()
|
|
404
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
405
|
+
|
|
406
|
+
if self._chat_task is task:
|
|
407
|
+
self._chat_task = None
|
|
408
|
+
self._pending_mcp_call = None
|
|
409
|
+
try:
|
|
410
|
+
await self.tts.abort()
|
|
411
|
+
except Exception:
|
|
412
|
+
logger.exception("failed to abort TTS session")
|
|
413
|
+
if self._pcm_resampler is not None:
|
|
414
|
+
self._pcm_resampler.reset()
|
|
415
|
+
self._opus_encoder.reset()
|
|
416
|
+
removed_audio_finish = False
|
|
417
|
+
while not self._queue.empty():
|
|
418
|
+
item = self._queue.get_nowait()
|
|
419
|
+
removed_audio_finish |= item.get("type") == "audio_finish"
|
|
420
|
+
self._audio_text = ""
|
|
421
|
+
self._audio_terminal_sent = True
|
|
422
|
+
if notify_host and (session_was_active or removed_audio_finish):
|
|
423
|
+
await self._queue.put({"type": "audio_finish", "data": ""})
|