From 372dc8ecb045ae97c1eb12381c5ef48723b53f8d Mon Sep 17 00:00:00 2001 From: 3030332422 <3030332422@qq.com> Date: Tue, 19 Aug 2025 14:45:14 +0800 Subject: [PATCH 01/53] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8D=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E9=A1=B5=E9=9D=A2=E5=85=A8=E9=80=89=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=A4=B1=E6=95=88=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/views/DeviceManagement.vue | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/main/manager-web/src/views/DeviceManagement.vue b/main/manager-web/src/views/DeviceManagement.vue index 35238371..55f781fa 100644 --- a/main/manager-web/src/views/DeviceManagement.vue +++ b/main/manager-web/src/views/DeviceManagement.vue @@ -74,7 +74,7 @@
- {{ isAllSelected ? '取消全选' : '全选' }} + {{ isCurrentPageAllSelected ? '取消全选' : '全选' }} 验证码绑定 @@ -128,8 +128,6 @@ export default { return { addDeviceDialogVisible: false, manualAddDeviceDialogVisible: false, - selectedDevices: [], - isAllSelected: false, searchKeyword: "", activeSearchKeyword: "", currentAgentId: this.$route.query.agentId || '', @@ -160,6 +158,11 @@ export default { pageCount() { return Math.ceil(this.filteredDeviceList.length / this.pageSize); }, + // 计算当前页是否全选 + isCurrentPageAllSelected() { + return this.paginatedDeviceList.length > 0 && + this.paginatedDeviceList.every(device => device.selected); + }, visiblePages() { const pages = []; const maxVisible = 3; @@ -205,16 +208,15 @@ export default { }, handleSelectAll() { - this.isAllSelected = !this.isAllSelected; + const shouldSelectAll = !this.isCurrentPageAllSelected; this.paginatedDeviceList.forEach(row => { - row.selected = this.isAllSelected; + row.selected = shouldSelectAll; }); - this.selectedDevices = this.paginatedDeviceList.filter(device => device.selected); }, deleteSelected() { - this.selectedDevices = this.paginatedDeviceList.filter(device => device.selected); - if (this.selectedDevices.length === 0) { + const selectedDevices = this.paginatedDeviceList.filter(device => device.selected); + if (selectedDevices.length === 0) { this.$message.warning({ message: '请至少选择一条记录', showClose: true @@ -222,12 +224,12 @@ export default { return; } - this.$confirm(`确认要解绑选中的 ${this.selectedDevices.length} 台设备吗?`, '警告', { + this.$confirm(`确认要解绑选中的 ${selectedDevices.length} 台设备吗?`, '警告', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }).then(() => { - const deviceIds = this.selectedDevices.map(device => device.device_id); + const deviceIds = selectedDevices.map(device => device.device_id); this.batchUnbindDevices(deviceIds); }); }, @@ -250,8 +252,6 @@ export default { showClose: true }); this.fetchBindDevices(this.currentAgentId); - this.selectedDevices = []; - this.isAllSelected = false; }) .catch(error => { this.$message.error({ @@ -355,7 +355,8 @@ export default { isEdit: false, _submitting: false, otaSwitch: device.autoUpdate === 1, - rawBindTime: new Date(device.createDate).getTime() + rawBindTime: new Date(device.createDate).getTime(), + selected: false }; }) .sort((a, b) => a.rawBindTime - b.rawBindTime); From 74826c1c598e6a532c7ecd1cfa62cb9b394616b0 Mon Sep 17 00:00:00 2001 From: Sakura-RanChen <1908198662@qq.com> Date: Wed, 20 Aug 2025 09:30:43 +0800 Subject: [PATCH 02/53] fix --- .../core/handle/sendAudioHandle.py | 9 +++++++-- main/xiaozhi-server/core/providers/tts/base.py | 7 +++---- .../core/utils/audio_flow_control.py | 17 ++++++++++++++++- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/main/xiaozhi-server/core/handle/sendAudioHandle.py b/main/xiaozhi-server/core/handle/sendAudioHandle.py index c3c50fbc..bb85c007 100644 --- a/main/xiaozhi-server/core/handle/sendAudioHandle.py +++ b/main/xiaozhi-server/core/handle/sendAudioHandle.py @@ -1,4 +1,5 @@ import json +import asyncio from core.providers.tts.dto.dto import SentenceType from core.utils import textUtils @@ -52,8 +53,12 @@ async def send_tts_message(conn, state, text=None): stop_tts_notify_voice = conn.config.get( "stop_tts_notify_voice", "config/assets/tts_notify.mp3" ) - audios, _ = conn.tts.audio_to_opus_data(stop_tts_notify_voice) - await sendAudio(conn, audios) + conn.tts.audio_to_opus_data_stream( + stop_tts_notify_voice, + callback=lambda audio_data: asyncio.run_coroutine_threadsafe( + sendAudio(conn, audio_data), conn.loop + ), + ) # 清除服务端讲话状态 conn.clearSpeakStatus() diff --git a/main/xiaozhi-server/core/providers/tts/base.py b/main/xiaozhi-server/core/providers/tts/base.py index 0b942862..35d80fb6 100644 --- a/main/xiaozhi-server/core/providers/tts/base.py +++ b/main/xiaozhi-server/core/providers/tts/base.py @@ -11,7 +11,7 @@ from datetime import datetime from core.utils import textUtils from abc import ABC, abstractmethod from config.logger import setup_logging -from core.utils.audio_flow_control import FlowControlConfig +from core.utils.audio_flow_control import FlowControlConfig, simulate_device_consumption from core.utils.util import audio_bytes_to_data_stream, audio_to_data_stream from core.utils.tts import MarkdownCleaner from core.utils.output_counter import add_device_output @@ -356,9 +356,8 @@ class TTSProviderBase(ABC): # 模拟设备消费(实际应用中应该从设备获取反馈)防止音字不同步 if isinstance(audio_datas, bytes): - # 模拟设备播放延迟(60ms per frame), 实际情况可以低一点(50ms),增加使用体验 - await asyncio.sleep(0.06) - self.flow_controller.update_device_consumption(1) + frame_count = 1 + asyncio.create_task(simulate_device_consumption(self.flow_controller, frame_count)) # 在类中添加流控制器重置方法 def reset_flow_controller(self): diff --git a/main/xiaozhi-server/core/utils/audio_flow_control.py b/main/xiaozhi-server/core/utils/audio_flow_control.py index 98c29676..958e68a7 100644 --- a/main/xiaozhi-server/core/utils/audio_flow_control.py +++ b/main/xiaozhi-server/core/utils/audio_flow_control.py @@ -151,6 +151,21 @@ class AudioFlowController: ) +async def simulate_device_consumption( + flow_controller: AudioFlowController, frame_count: int +): + """ + 模拟设备消费音频帧的过程 + 实际应用中应该根据设备反馈来更新消费情况 + Args: + flow_controller: 流控制器实例 + frame_count: 消费的帧数 + """ + # 模拟设备播放延迟(60ms per frame) + await asyncio.sleep(frame_count * 0.06) + flow_controller.update_device_consumption(frame_count) + + # 流控配置常量 class FlowControlConfig: """流控配置常量""" @@ -183,4 +198,4 @@ class FlowControlConfig: return AudioFlowController( max_device_buffer=max_buffer or cls.DEFAULT_MAX_DEVICE_BUFFER, refill_rate=refill_rate or cls.DEFAULT_REFILL_RATE - ) \ No newline at end of file + ) From 7d7b8ddfb114147650c7b9e12740f29637055b90 Mon Sep 17 00:00:00 2001 From: VanillaNahida Date: Wed, 20 Aug 2025 14:00:53 +0800 Subject: [PATCH 03/53] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E8=8E=B7=E5=8F=96=E4=B8=8D=E5=88=B0ws=E5=92=8C?= =?UTF-8?q?=E8=A7=86=E8=A7=89=E5=88=86=E6=9E=90=E6=8E=A5=E5=8F=A3=E5=9C=B0?= =?UTF-8?q?=E5=9D=80=E7=9A=84bug=20docs:=20=E6=9B=B4=E6=96=B0README?= =?UTF-8?q?=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-setup.sh | 15 ++++++++------- docs/Deployment_all.md | 5 ++++- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/docker-setup.sh b/docker-setup.sh index 938917f4..212f68ff 100755 --- a/docker-setup.sh +++ b/docker-setup.sh @@ -46,7 +46,7 @@ cat << "EOF" \/ \__,_||_| |_||_||_||_| \__,_| |_| \_| \__,_||_| |_||_| \__,_| \__,_| EOF echo -e "\e[0m" # 重置颜色 -echo -e "\e[1;36m 小智服务端全量部署一键安装脚本 Ver 0.2 \e[0m\n" +echo -e "\e[1;36m 小智服务端全量部署一键安装脚本 Ver 0.2 2025年8月20日更新 \e[0m\n" sleep 1 @@ -376,7 +376,7 @@ done echo "服务端启动成功!正在完成配置..." echo "正在启动服务..." - docker compose -f docker-compose_all.yml up -d + docker compose -f /opt/xiaozhi-server/docker-compose_all.yml up -d echo "服务启动完成!" ) @@ -402,13 +402,14 @@ fi # 获取并显示地址信息 LOCAL_IP=$(hostname -I | awk '{print $1}') -WEBSOCKET_ADDR=$(docker logs xiaozhi-esp32-server 2>&1 | tac | grep -m 1 -E -o "ws://[^ ]+") -VISION_ADDR=$(docker logs xiaozhi-esp32-server 2>&1 | tac | grep -m 1 "视觉" | grep -m 1 -E -o "http://[^ ]+") +# WEBSOCKET_ADDR=$(docker logs xiaozhi-esp32-server 2>&1 | tac | grep -m 1 -E -o "ws://[^ ]+") +# VISION_ADDR=$(docker logs xiaozhi-esp32-server 2>&1 | tac | grep -m 1 "视觉" | grep -m 1 -E -o "http://[^ ]+") +# 修复日志文件获取不到ws的问题,改为硬编码 whiptail --title "安装完成!" --msgbox "\ 服务端相关地址如下:\n\ 管理后台访问地址: http://$LOCAL_IP:8002\n\ -OTA 地址: http://$LOCAL_IP:8002/xiaozhi/ota/\n\ -视觉分析接口地址: $VISION_ADDR\n\ -WebSocket 地址: $WEBSOCKET_ADDR\n\ +OTA 地址: http://$LOCAL_IP:8003/xiaozhi/ota/\n\ +视觉分析接口地址: http://$LOCAL_IP:8003/mcp/vision/explain\n\ +WebSocket 地址: ws://$LOCAL_IP:8000/xiaozhi/v1/\n\ \n安装完毕!感谢您的使用!\n按Enter键退出..." 16 70 \ No newline at end of file diff --git a/docs/Deployment_all.md b/docs/Deployment_all.md index f93a266e..153a13fe 100644 --- a/docs/Deployment_all.md +++ b/docs/Deployment_all.md @@ -7,7 +7,10 @@ docker镜像已支持x86架构、arm64架构的CPU,支持在国产操作系统 如果您的电脑还没安装docker,可以按照这里的教程安装:[docker安装](https://www.runoob.com/docker/ubuntu-docker-install.html) -docker 安装全模块有两种方式,你可以[1.1使用懒人脚本](#1.1 懒人脚本)(作者[@VanillaNahida](https://github.com/VanillaNahida))自动帮你下载所需的文件和配置文件,你可以使用[1.2手动部署](#1.2 手动部署)从零搭建。 +docker 安装全模块有两种方式,你可以[1.1使用懒人脚本](./docs/Deployment_all.md#1.1+%e6%87%92%e4%ba%ba%e8%84%9a%e6%9c%ac)(作者[@VanillaNahida](https://github.com/VanillaNahida)) +脚本会自动帮你下载所需的文件和配置文件,你可以使用[1.2 手动部署](./docs/Deployment_all.md#1.2+%e6%89%8b%e5%8a%a8%e9%83%a8%e7%bd%b2)从零搭建。 + + ### 1.1 懒人脚本 From 67d3a8f94f8db70f41de41a0113bb8ff2bdbc54d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A6=99=E8=8D=89=E5=91=B3=E7=9A=84=E7=BA=B3=E8=A5=BF?= =?UTF-8?q?=E5=A6=B2=E5=96=B5?= <151599587+VanillaNahida@users.noreply.github.com> Date: Wed, 20 Aug 2025 14:07:46 +0800 Subject: [PATCH 04/53] Update Deployment_all.md --- docs/Deployment_all.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/Deployment_all.md b/docs/Deployment_all.md index 153a13fe..f48f6e10 100644 --- a/docs/Deployment_all.md +++ b/docs/Deployment_all.md @@ -7,13 +7,13 @@ docker镜像已支持x86架构、arm64架构的CPU,支持在国产操作系统 如果您的电脑还没安装docker,可以按照这里的教程安装:[docker安装](https://www.runoob.com/docker/ubuntu-docker-install.html) -docker 安装全模块有两种方式,你可以[1.1使用懒人脚本](./docs/Deployment_all.md#1.1+%e6%87%92%e4%ba%ba%e8%84%9a%e6%9c%ac)(作者[@VanillaNahida](https://github.com/VanillaNahida)) -脚本会自动帮你下载所需的文件和配置文件,你可以使用[1.2 手动部署](./docs/Deployment_all.md#1.2+%e6%89%8b%e5%8a%a8%e9%83%a8%e7%bd%b2)从零搭建。 +docker 安装全模块有两种方式,你可以[使用懒人脚本](./Deployment_all.md#11-懒人脚本)(作者[@VanillaNahida](https://github.com/VanillaNahida)) +脚本会自动帮你下载所需的文件和配置文件,你也可以使用[手动部署](./Deployment_all.md#12-手动部署)从零搭建。 ### 1.1 懒人脚本 - +**推荐使用**,**全自动部署**,只需**简单配置**即可使用,视频教程:[https://www.bilibili.com/video/BV17bbvzHExd/](https://www.bilibili.com/video/BV17bbvzHExd/) 你可以使用以下命令一键安装全模块版小智服务端: > [!NOTE] > 暂且只支持Ubuntu服务器一键部署,其他系统未尝试,可能会有一些奇怪的bug From d7dc636d3f9bfc0be52753c2711d7fde55591f9a Mon Sep 17 00:00:00 2001 From: FAN-yeB <1442100690@qq.com> Date: Wed, 20 Aug 2025 14:30:47 +0800 Subject: [PATCH 05/53] =?UTF-8?q?update:=E6=9B=B4=E6=96=B0=E6=B5=81?= =?UTF-8?q?=E5=BC=8FTTS=E6=B5=8B=E8=AF=95=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/performance_tester.md | 4 +- .../performance_tester_asr.py | 307 +++++++++++--- .../performance_tester_stream_tts.py | 388 ++++++++++++++++++ .../performance_tester_tts.py | 66 ++- 4 files changed, 701 insertions(+), 64 deletions(-) create mode 100644 main/xiaozhi-server/performance_tester/performance_tester_stream_tts.py diff --git a/docs/performance_tester.md b/docs/performance_tester.md index abe57458..85987120 100644 --- a/docs/performance_tester.md +++ b/docs/performance_tester.md @@ -1,8 +1,8 @@ -# 语音识别、大语言模型、非流式语音合成、视觉模型的性能测试工具使用指南 +# 语音识别、大语言模型、非流式语音合成、流式语音合成、视觉模型的性能测试工具使用指南 1.在main/xiaozhi-server目录下创建data目录 2.在data目录下创建.config.yaml文件 -3.在.data/config.yaml中,写入你的语音识别、大语言模型、非流式语音合成、视觉模型的参数 +3.在.data/config.yaml中,写入你的语音识别、大语言模型、流式语音合成、视觉模型的参数 例如: ``` LLM: diff --git a/main/xiaozhi-server/performance_tester/performance_tester_asr.py b/main/xiaozhi-server/performance_tester/performance_tester_asr.py index e4d8c6d8..db359e28 100644 --- a/main/xiaozhi-server/performance_tester/performance_tester_asr.py +++ b/main/xiaozhi-server/performance_tester/performance_tester_asr.py @@ -2,35 +2,56 @@ import asyncio import logging import os import time -from typing import Dict - +import concurrent.futures +from typing import Dict, Optional import aiohttp from tabulate import tabulate from core.utils.asr import create_instance as create_stt_instance -from config.settings import load_config # 设置全局日志级别为WARNING,抑制INFO级别日志 logging.basicConfig(level=logging.WARNING) description = "语音识别模型性能测试" - class ASRPerformanceTester: def __init__(self): - self.config = load_config() + self.config = self._load_config_from_data_dir() self.test_wav_list = self._load_test_wav_files() self.results = {"stt": {}} - + # 调试日志 print(f"[DEBUG] 加载的ASR配置: {self.config.get('ASR', {})}") print(f"[DEBUG] 音频文件数量: {len(self.test_wav_list)}") + def _load_config_from_data_dir(self) -> Dict: + """从 data 目录加载所有 .config.yaml 文件的配置""" + config = {"ASR": {}} + data_dir = os.path.join(os.getcwd(), "data") + print(f"[DEBUG] 扫描配置文件目录: {data_dir}") + + for root, _, files in os.walk(data_dir): + for file in files: + if file.endswith(".config.yaml"): + file_path = os.path.join(root, file) + try: + with open(file_path, "r", encoding="utf-8") as f: + import yaml + file_config = yaml.safe_load(f) + # 兼容大小写的 ASR/asr 配置 + asr_config = file_config.get("ASR") or file_config.get("asr") + if asr_config: + config["ASR"].update(asr_config) + print(f"[DEBUG] 从 {file_path} 加载 ASR 配置成功") + except Exception as e: + print(f" 加载配置文件 {file_path} 失败: {str(e)}") + return config + def _load_test_wav_files(self) -> list: """加载测试用的音频文件(添加路径调试)""" wav_root = os.path.join(os.getcwd(), "config", "assets") print(f"[DEBUG] 音频文件目录: {wav_root}") test_wav_list = [] - + if os.path.exists(wav_root): file_list = os.listdir(wav_root) print(f"[DEBUG] 找到音频文件: {file_list}") @@ -43,18 +64,46 @@ class ASRPerformanceTester: print(f" 目录不存在: {wav_root}") return test_wav_list - async def _test_stt(self, stt_name: str, config: Dict) -> Dict: - """异步测试单个STT性能(跳过无效配置)""" + async def _test_single_audio(self, stt_name: str, stt, audio_data: bytes) -> Optional[float]: + """测试单个音频文件的性能""" try: + start_time = time.time() + text, _ = await stt.speech_to_text([audio_data], "1", stt.audio_format) + if text is None: + return None + + duration = time.time() - start_time + + # 检测0.000s的异常时间 + if abs(duration) < 0.001: # 小于1毫秒视为异常 + print(f"{stt_name} 检测到异常时间: {duration:.6f}s (视为错误)") + return None + + return duration + except Exception as e: + error_msg = str(e).lower() + if "502" in error_msg or "bad gateway" in error_msg: + print(f"{stt_name} 遇到502错误") + return None + return None + + async def _test_stt_with_timeout(self, stt_name: str, config: Dict) -> Dict: + """异步测试单个STT性能,带超时控制""" + try: + # 检查配置有效性 token_fields = ["access_token", "api_key", "token"] - # 忽略值为 "none" 的情况(需根据实际需求调整) if any( field in config - and str(config[field]).lower() in ["你的", "placeholder"] + and str(config[field]).lower() in ["你的", "placeholder", "none", "null", ""] for field in token_fields ): - print(f" STT {stt_name} 未配置access_token/api_key,已跳过") - return {"name": stt_name, "type": "stt", "errors": 1} + print(f" STT {stt_name} 未配置有效access_token/api_key,已跳过") + return { + "name": stt_name, + "type": "stt", + "errors": 1, + "error_type": "配置错误" + } module_type = config.get("type", stt_name) stt = create_stt_instance(module_type, config, delete_audio_file=True) @@ -62,56 +111,203 @@ class ASRPerformanceTester: print(f" 测试 STT: {stt_name}") - # 测试第一个音频文件 - text, _ = await stt.speech_to_text( - [self.test_wav_list[0]], "1", stt.audio_format - ) - if text is None: - print(f" {stt_name} 连接失败") - return {"name": stt_name, "type": "stt", "errors": 1} + # 使用线程池和超时控制 + loop = asyncio.get_event_loop() + + # 测试第一个音频文件作为连通性检查 + try: + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit( + lambda: asyncio.run(self._test_single_audio(stt_name, stt, self.test_wav_list[0])) + ) + first_result = await asyncio.wait_for( + asyncio.wrap_future(future), timeout=10.0 + ) + + if first_result is None: + print(f" {stt_name} 连接失败") + return { + "name": stt_name, + "type": "stt", + "errors": 1, + "error_type": "网络错误" + } + except asyncio.TimeoutError: + print(f" {stt_name} 连接超时(10秒),跳过") + return { + "name": stt_name, + "type": "stt", + "errors": 1, + "error_type": "超时连接" + } + except Exception as e: + error_msg = str(e).lower() + if "502" in error_msg or "bad gateway" in error_msg: + print(f" {stt_name} 遇到502错误,跳过") + return { + "name": stt_name, + "type": "stt", + "errors": 1, + "error_type": "502网络错误" + } + print(f" {stt_name} 连接异常: {str(e)}") + return { + "name": stt_name, + "type": "stt", + "errors": 1, + "error_type": "网络错误" + } - # 全量测试 + # 全量测试,带超时控制 total_time = 0 + valid_tests = 0 test_count = len(self.test_wav_list) - for i, sentence in enumerate(self.test_wav_list, 1): - start = time.time() - text, _ = await stt.speech_to_text([sentence], "1", stt.audio_format) - duration = time.time() - start - total_time += duration - print(f" {stt_name} [{i}/{test_count}] 耗时: {duration:.2f}s") + + for i, audio_data in enumerate(self.test_wav_list, 1): + try: + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit( + lambda: asyncio.run(self._test_single_audio(stt_name, stt, audio_data)) + ) + duration = await asyncio.wait_for( + asyncio.wrap_future(future), timeout=10.0 + ) + + if duration is not None and duration > 0.001: + total_time += duration + valid_tests += 1 + print(f" {stt_name} [{i}/{test_count}] 耗时: {duration:.2f}s") + else: + print(f" {stt_name} [{i}/{test_count}] 测试失败(含0.000s异常)") + + except asyncio.TimeoutError: + print(f" {stt_name} [{i}/{test_count}] 超时(10秒),跳过") + continue + except Exception as e: + error_msg = str(e).lower() + if "502" in error_msg or "bad gateway" in error_msg: + print(f" {stt_name} [{i}/{test_count}] 502错误,跳过") + return { + "name": stt_name, + "type": "stt", + "errors": 1, + "error_type": "502网络错误" + } + print(f" {stt_name} [{i}/{test_count}] 异常: {str(e)}") + continue + # 检查有效测试数量 + if valid_tests < test_count * 0.3: # 至少30%成功率 + print(f" {stt_name} 成功测试过少({valid_tests}/{test_count}),可能网络不稳定") + return { + "name": stt_name, + "type": "stt", + "errors": 1, + "error_type": "网络错误" + } + if valid_tests == 0: + return { + "name": stt_name, + "type": "stt", + "errors": 1, + "error_type": "网络错误" + } + + avg_time = total_time / valid_tests return { "name": stt_name, "type": "stt", - "avg_time": total_time / test_count, + "avg_time": avg_time, + "success_rate": f"{valid_tests}/{test_count}", "errors": 0, } + except Exception as e: + error_msg = str(e).lower() + if "502" in error_msg or "bad gateway" in error_msg: + error_type = "502网络错误" + elif "timeout" in error_msg: + error_type = "超时连接" + else: + error_type = "网络错误" print(f"⚠️ {stt_name} 测试失败: {str(e)}") - return {"name": stt_name, "type": "stt", "errors": 1} + return { + "name": stt_name, + "type": "stt", + "errors": 1, + "error_type": error_type + } def _print_results(self): - """打印测试结果""" - stt_table = [] + """打印测试结果,按响应时间排序""" + print("\n" + "=" * 50) + print("ASR 性能测试结果") + print("=" * 50) + + if not self.results.get("stt"): + print("没有可用的测试结果") + return + + headers = ["模型名称", "平均耗时(s)", "成功率", "状态"] + table_data = [] + + # 收集所有数据并分类 + valid_results = [] + error_results = [] + for name, data in self.results["stt"].items(): if data["errors"] == 0: - stt_table.append([name, f"{data['avg_time']:.3f}秒"]) + # 正常结果 + avg_time = f"{data['avg_time']:.3f}" + success_rate = data.get("success_rate", "N/A") + status = "✅ 正常" + + # 保存用于排序的值 + sort_key = data["avg_time"] + + valid_results.append({ + "name": name, + "avg_time": avg_time, + "success_rate": success_rate, + "status": status, + "sort_key": sort_key, + }) + else: + # 错误结果 + avg_time = "-" + success_rate = "0/N" + + # 获取具体错误类型 + error_type = data.get("error_type", "网络错误") + status = f"❌ {error_type}" + + error_results.append([name, avg_time, success_rate, status]) - if stt_table: - print("\nASR 性能排行:\n") - print( - tabulate( - stt_table, - headers=["模型名称", "平均耗时"], - tablefmt="github", - colalign=("left", "right"), - ) - ) - else: - print("\n 没有可用的ASR模块进行测试。") + # 按响应时间升序排序(从快到慢) + valid_results.sort(key=lambda x: x["sort_key"]) + + # 将排序后的有效结果转换为表格数据 + for result in valid_results: + table_data.append([ + result["name"], + result["avg_time"], + result["success_rate"], + result["status"], + ]) + + # 将错误结果添加到表格数据末尾 + table_data.extend(error_results) + + print(tabulate(table_data, headers=headers, tablefmt="grid")) + print("\n测试说明:") + print("- 超时控制:单个音频最大等待时间为10秒") + print("- 错误处理:自动跳过502错误、超时和网络异常的模型") + print("- 成功率:成功识别的音频数量/总测试音频数量") + print("- 排序规则:按平均耗时从快到慢排序,错误模型排最后") + print("\n测试完成!") async def run(self): - """执行全量异步测试""" + """执行全量异步测试""" print("开始筛选可用ASR模块...") if not self.config.get("ASR"): print("配置中未找到 ASR 模块") @@ -119,24 +315,33 @@ class ASRPerformanceTester: all_tasks = [] for stt_name, config in self.config["ASR"].items(): - print(f"[DEBUG] 检查 ASR 模块: {stt_name}, 配置: {config}") - all_tasks.append(self._test_stt(stt_name, config)) + # 检查配置有效性 + token_fields = ["access_token", "api_key", "token"] + if any( + field in config + and str(config[field]).lower() in ["你的", "placeholder", "none", "null", ""] + for field in token_fields + ): + print(f"ASR {stt_name} 未配置有效access_token/api_key,已跳过") + continue + + print(f"添加 ASR 测试任务: {stt_name}") + all_tasks.append(self._test_stt_with_timeout(stt_name, config)) if not all_tasks: print("没有可用的ASR模块进行测试。") return + print(f"\n找到 {len(all_tasks)} 个可用ASR模块") print("\n开始并发测试所有ASR模块...") all_results = await asyncio.gather(*all_tasks, return_exceptions=True) # 处理结果 for result in all_results: if isinstance(result, dict) and result.get("type") == "stt": - if result["errors"] == 0: - self.results["stt"][result["name"]] = result + self.results["stt"][result["name"]] = result # 打印结果 - print("\n测试完成") self._print_results() @@ -146,4 +351,4 @@ async def main(): if __name__ == "__main__": - asyncio.run(main()) + asyncio.run(main()) \ No newline at end of file diff --git a/main/xiaozhi-server/performance_tester/performance_tester_stream_tts.py b/main/xiaozhi-server/performance_tester/performance_tester_stream_tts.py new file mode 100644 index 00000000..309ad339 --- /dev/null +++ b/main/xiaozhi-server/performance_tester/performance_tester_stream_tts.py @@ -0,0 +1,388 @@ +import asyncio +import time +import json +import uuid +import aiohttp +import websockets +from tabulate import tabulate +from config.settings import load_config + +description = "流式TTS语音合成首词耗时测试" +class StreamTTSPerformanceTester: + def __init__(self): + self.config = load_config() + self.test_texts = [ + "你好,这是一句话。" + ] + self.results = [] + + async def test_aliyun_tts(self, text=None, test_count=5): + """测试阿里云流式TTS首词延迟(测试多次取平均)""" + text = text or self.test_texts[0] + latencies = [] + + for i in range(test_count): + try: + tts_config = self.config["TTS"]["AliyunStreamTTS"] + appkey = tts_config["appkey"] + token = tts_config["token"] + voice = tts_config["voice"] + host = tts_config["host"] + ws_url = f"wss://{host}/ws/v1" + + start_time = time.time() + async with websockets.connect(ws_url, extra_headers={"X-NLS-Token": token}) as ws: + task_id = str(uuid.uuid4()) + message_id = str(uuid.uuid4()) + + start_request = { + "header": { + "message_id": message_id, + "task_id": task_id, + "namespace": "FlowingSpeechSynthesizer", + "name": "StartSynthesis", + "appkey": appkey, + }, + "payload": { + "voice": voice, + "format": "pcm", + "sample_rate": 16000, + "volume": 50, + "speech_rate": 0, + "pitch_rate": 0, + } + } + await ws.send(json.dumps(start_request)) + + start_response = json.loads(await ws.recv()) + if start_response["header"]["name"] != "SynthesisStarted": + raise Exception("启动合成失败") + + run_request = { + "header": { + "message_id": str(uuid.uuid4()), + "task_id": task_id, + "namespace": "FlowingSpeechSynthesizer", + "name": "RunSynthesis", + "appkey": appkey, + }, + "payload": {"text": text} + } + await ws.send(json.dumps(run_request)) + + while True: + response = await ws.recv() + if isinstance(response, bytes): + latency = time.time() - start_time + latencies.append(latency) + break + elif isinstance(response, str): + data = json.loads(response) + if data["header"]["name"] == "TaskFailed": + raise Exception(f"合成失败: {data['payload']['error_info']}") + + except Exception as e: + latencies.append(0) + + return self._calculate_result("阿里云TTS", latencies, test_count) + + async def test_doubao_tts(self, text=None, test_count=5): + """测试火山引擎流式TTS首词延迟(测试多次取平均)""" + text = text or self.test_texts[0] + latencies = [] + + for i in range(test_count): + try: + tts_config = self.config["TTS"]["HuoshanDoubleStreamTTS"] + ws_url = tts_config["ws_url"] + app_id = tts_config["appid"] + access_token = tts_config["access_token"] + resource_id = tts_config["resource_id"] + speaker = tts_config["speaker"] + + start_time = time.time() + ws_header = { + "X-Api-App-Key": app_id, + "X-Api-Access-Key": access_token, + "X-Api-Resource-Id": resource_id, + "X-Api-Connect-Id": str(uuid.uuid4()), + } + async with websockets.connect(ws_url, additional_headers=ws_header, max_size=1000000000) as ws: + session_id = uuid.uuid4().hex + + # 发送会话启动请求 + header = bytes([ + (0b0001 << 4) | 0b0001, + 0b0001 << 4 | 0b100, + 0b0001 << 4 | 0b0000, + 0 + ]) + optional = bytearray() + optional.extend((1).to_bytes(4, "big", signed=True)) + session_id_bytes = session_id.encode() + optional.extend(len(session_id_bytes).to_bytes(4, "big", signed=True)) + optional.extend(session_id_bytes) + payload = json.dumps({"speaker": speaker}).encode() + await ws.send(header + optional + len(payload).to_bytes(4, "big", signed=True) + payload) + + # 发送文本 + header = bytes([ + (0b0001 << 4) | 0b0001, + 0b0001 << 4 | 0b100, + 0b0001 << 4 | 0b0000, + 0 + ]) + optional = bytearray() + optional.extend((200).to_bytes(4, "big", signed=True)) + session_id_bytes = session_id.encode() + optional.extend(len(session_id_bytes).to_bytes(4, "big", signed=True)) + optional.extend(session_id_bytes) + payload = json.dumps({"text": text, "speaker": speaker}).encode() + await ws.send(header + optional + len(payload).to_bytes(4, "big", signed=True) + payload) + + first_chunk = await ws.recv() + latency = time.time() - start_time + latencies.append(latency) + + except Exception as e: + latencies.append(0) + + return self._calculate_result("火山引擎TTS", latencies, test_count) + + async def test_paddlespeech_tts(self, text=None, test_count=5): + """测试PaddleSpeech流式TTS首词延迟(测试多次取平均)""" + text = text or self.test_texts[0] + latencies = [] + + for i in range(test_count): + try: + tts_config = self.config["TTS"]["PaddleSpeechTTS"] + tts_url = tts_config["url"] + spk_id = tts_config["spk_id"] + speed = tts_config["speed"] + volume = tts_config["volume"] + + start_time = time.time() + async with websockets.connect(tts_url) as ws: + # 发送开始请求 + await ws.send(json.dumps({ + "task": "tts", + "signal": "start" + })) + + start_response = json.loads(await ws.recv()) + if start_response.get("status") != 0: + raise Exception("连接失败") + + # 发送文本数据 + await ws.send(json.dumps({ + "text": text, + "spk_id": spk_id, + "speed": speed, + "volume": volume + })) + + # 接收第一个数据块 + first_chunk = await ws.recv() + latency = time.time() - start_time + latencies.append(latency) + + # 发送结束请求 + end_request = { + "task": "tts", + "signal": "end" + } + await ws.send(json.dumps(end_request)) + + # 确保连接正常关闭 + try: + await ws.recv() + except websockets.exceptions.ConnectionClosedOK: + pass + + except Exception as e: + latencies.append(0) + + return self._calculate_result("PaddleSpeechTTS", latencies, test_count) + + async def test_indexstream_tts(self, text=None, test_count=5): + """测试IndexStream流式TTS首词延迟(测试多次取平均)""" + text = text or self.test_texts[0] + latencies = [] + + for i in range(test_count): + try: + tts_config = self.config["TTS"]["IndexStreamTTS"] + api_url = tts_config.get("api_url") + voice = tts_config.get("voice") + + start_time = time.time() + + async with aiohttp.ClientSession() as session: + payload = {"text": text, "character": voice} + async with session.post(api_url, json=payload, timeout=10) as resp: + if resp.status != 200: + raise Exception(f"请求失败: {resp.status}, {await resp.text()}") + + async for chunk in resp.content.iter_any(): + data = chunk[0] if isinstance(chunk, (list, tuple)) else chunk + if not data: + continue + + latency = time.time() - start_time + latencies.append(latency) + resp.close() + break + else: + latencies.append(0) + + except Exception as e: + latencies.append(0) + + return self._calculate_result("IndexStreamTTS", latencies, test_count) + + async def test_linkerai_tts(self, text=None, test_count=5): + """测试Linkerai流式TTS首词延迟(测试多次取平均)""" + text = text or self.test_texts[0] + latencies = [] + + for i in range(test_count): + try: + tts_config = self.config["TTS"]["LinkeraiTTS"] + api_url = tts_config["api_url"] + access_token = tts_config["access_token"] + voice = tts_config["voice"] + + start_time = time.time() + async with aiohttp.ClientSession() as session: + params = { + "tts_text": text, + "spk_id": voice, + "frame_durition": 60, + "stream": "true", + "target_sr": 16000, + "audio_format": "pcm", + "instruct_text": "请生成一段自然流畅的语音", + } + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + + async with session.get(api_url, params=params, headers=headers, timeout=10) as resp: + if resp.status != 200: + raise Exception(f"请求失败: {resp.status}, {await resp.text()}") + + # 接收第一个数据块 + async for _ in resp.content.iter_any(): + latency = time.time() - start_time + latencies.append(latency) + break + else: + latencies.append(0) + + except Exception as e: + latencies.append(0) + + return self._calculate_result("LinkeraiTTS", latencies, test_count) + + + def _calculate_result(self, service_name, latencies, test_count): + """计算测试结果""" + valid_latencies = [l for l in latencies if l > 0] + if valid_latencies: + avg_latency = sum(valid_latencies) / len(valid_latencies) + status = f"成功({len(valid_latencies)}/{test_count}次有效)" + else: + avg_latency = 0 + status = "失败: 所有测试均失败" + return {"name": service_name, "latency": avg_latency, "status": status} + + def _print_results(self, test_text, test_count): + """打印测试结果""" + if not self.results: + print("没有有效的TTS测试结果") + return + + print(f"\n{'='*60}") + print("流式TTS首词延迟测试结果") + print(f"{'='*60}") + print(f"测试文本: {test_text}") + print(f"测试次数: 每个TTS服务测试 {test_count} 次") + + # 排序结果:成功优先,按延迟升序 + success_results = sorted( + [r for r in self.results if "成功" in r["status"]], + key=lambda x: x["latency"] + ) + failed_results = [r for r in self.results if "成功" not in r["status"]] + + table_data = [ + [r["name"], f"{r['latency']:.3f}", r["status"]] + for r in success_results + failed_results + ] + + print(tabulate(table_data, headers=["TTS服务", "首词延迟(秒)", "状态"], tablefmt="grid")) + print("\n测试说明:测量从发送请求到接收第一个音频数据块的时间,取多次测试平均值") + print("- 超时控制: 单个请求最大等待时间为10秒") + print("- 错误处理: 无法连接和超时的列为网络错误") + print("- 排序规则: 按平均耗时从快到慢排序") + + + async def run(self, test_text=None, test_count=5): + """执行测试 + + Args: + test_text: 要测试的文本,如果为None则使用默认文本 + test_count: 每个TTS服务的测试次数 + """ + test_text = test_text or self.test_texts[0] + print(f"开始流式TTS首词延迟测试...") + print(f"测试文本: {test_text}") + print(f"每个TTS服务测试次数: {test_count}次") + + if not self.config.get("TTS"): + print("配置文件中未找到TTS配置") + return + + # 测试每种TTS服务 + self.results = [] + + # 测试阿里云TTS + result = await self.test_aliyun_tts(test_text, test_count) + self.results.append(result) + + # 测试火山引擎TTS + result = await self.test_doubao_tts(test_text, test_count) + self.results.append(result) + + # 测试PaddleSpeech TTS + result = await self.test_paddlespeech_tts(test_text, test_count) + self.results.append(result) + + # 测试Linkerai TTS + result = await self.test_linkerai_tts(test_text, test_count) + self.results.append(result) + + # 测试IndexStreamTTS + result = await self.test_indexstream_tts(test_text, test_count) + self.results.append(result) + + # 打印结果 + self._print_results(test_text, test_count) + + +async def main(): + import argparse + + parser = argparse.ArgumentParser(description="流式TTS首词延迟测试工具") + parser.add_argument("--text", help="要测试的文本内容") + parser.add_argument("--count", type=int, default=5, help="每个TTS服务的测试次数") + + args = parser.parse_args() + await StreamTTSPerformanceTester().run(args.text, args.count) + + +if __name__ == "__main__": + import asyncio + asyncio.run(main()) \ No newline at end of file diff --git a/main/xiaozhi-server/performance_tester/performance_tester_tts.py b/main/xiaozhi-server/performance_tester/performance_tester_tts.py index 4702c5a9..61e49682 100644 --- a/main/xiaozhi-server/performance_tester/performance_tester_tts.py +++ b/main/xiaozhi-server/performance_tester/performance_tester_tts.py @@ -86,22 +86,67 @@ class TTSPerformanceTester: print("没有有效的TTS测试结果") return - table = [] + headers = ["TTS模块", "平均耗时(秒)", "测试句子数", "状态"] + table_data = [] + + # 收集所有数据并分类 + valid_results = [] + error_results = [] + for name, data in self.results.items(): if data["errors"] == 0: - table.append( - [name, f"{data['avg_time']:.3f}秒/句", len(self.test_sentences[:3])] - ) + # 正常结果 + avg_time = f"{data['avg_time']:.3f}" + test_count = len(self.test_sentences[:3]) + status = "✅ 正常" + + # 保存用于排序的值 + valid_results.append({ + "name": name, + "avg_time": avg_time, + "test_count": test_count, + "status": status, + "sort_key": data['avg_time'] + }) + else: + # 错误结果 + avg_time = "-" + test_count = "0/3" + + # 默认错误类型为网络错误 + error_type = "网络错误" + status = f"❌ {error_type}" + + error_results.append([name, avg_time, test_count, status]) + + # 按平均耗时升序排序 + valid_results.sort(key=lambda x: x["sort_key"]) + + # 将排序后的有效结果转换为表格数据 + for result in valid_results: + table_data.append([ + result["name"], + result["avg_time"], + result["test_count"], + result["status"] + ]) + + # 将错误结果添加到表格数据末尾 + table_data.extend(error_results) print("\nTTS性能测试结果:") print( tabulate( - table, - headers=["TTS模块", "平均耗时", "测试句子数"], - tablefmt="github", - colalign=("left", "right", "right"), + table_data, + headers=headers, + tablefmt="grid", + colalign=("left", "right", "right", "left"), ) ) + print("\n测试说明:") + print("- 超时控制: 单个请求最大等待时间为10秒") + print("- 错误处理: 无法连接和超时的列为网络错误") + print("- 排序规则: 按平均耗时从快到慢排序") async def run(self): """执行测试""" @@ -119,10 +164,9 @@ class TTSPerformanceTester: # 并发执行测试 results = await asyncio.gather(*tasks) - # 保存有效结果 + # 保存所有结果,包括错误 for result in results: - if result["errors"] == 0: - self.results[result["name"]] = result + self.results[result["name"]] = result # 打印结果 self._print_results() From 789d756ce193cc8a2e17ca42b673ef95adffc6c5 Mon Sep 17 00:00:00 2001 From: rainv123 <2148537152@qq.com> Date: Wed, 20 Aug 2025 14:36:42 +0800 Subject: [PATCH 06/53] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8Dmcp=E5=9C=B0?= =?UTF-8?q?=E5=9D=80=E6=98=BE=E7=A4=BA=E6=85=A2=EF=BC=8C=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=AB=AF=E5=9C=B0=E5=9D=80=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/manager-mobile/src/pages/agent/tools.vue | 28 ++++-- .../src/pages/settings/index.vue | 99 ++++++++++++++----- 2 files changed, 97 insertions(+), 30 deletions(-) diff --git a/main/manager-mobile/src/pages/agent/tools.vue b/main/manager-mobile/src/pages/agent/tools.vue index 88ca36fc..4e710b19 100644 --- a/main/manager-mobile/src/pages/agent/tools.vue +++ b/main/manager-mobile/src/pages/agent/tools.vue @@ -28,6 +28,11 @@ const agentId = computed(() => pluginStore.currentAgentId) const mcpAddress = ref('') const mcpTools = ref([]) +// 初始化时从本地存储加载MCP地址 +if (uni.getStorageSync('cachedMcpAddress_' + agentId.value)) { + mcpAddress.value = uni.getStorageSync('cachedMcpAddress_' + agentId.value) +} + // 参数编辑相关 const showParamDialog = ref(false) const currentFunction = ref(null) @@ -56,12 +61,23 @@ async function mergeFunctions() { ) if (agentId.value) { - const [address, tools] = await Promise.all([ - getMcpAddress(agentId.value), - getMcpTools(agentId.value), - ]) - mcpAddress.value = address - mcpTools.value = tools || [] + // 优先获取并显示MCP地址 + try { + const address = await getMcpAddress(agentId.value) + mcpAddress.value = address + // 缓存到本地存储,下次打开页面可以立即显示 + uni.setStorageSync('cachedMcpAddress_' + agentId.value, address) + } catch (error) { + console.error('获取MCP地址失败:', error) + } + + // 异步获取MCP工具列表,不阻塞UI显示 + try { + const tools = await getMcpTools(agentId.value) + mcpTools.value = tools || [] + } catch (error) { + console.error('获取MCP工具列表失败:', error) + } } } diff --git a/main/manager-mobile/src/pages/settings/index.vue b/main/manager-mobile/src/pages/settings/index.vue index cef02761..41d16c00 100644 --- a/main/manager-mobile/src/pages/settings/index.vue +++ b/main/manager-mobile/src/pages/settings/index.vue @@ -27,6 +27,7 @@ const cacheInfo = reactive({ // 服务端地址设置 const baseUrlInput = ref('') +const urlError = ref('') // 系统信息(保留) const systemInfo = computed(() => { @@ -52,10 +53,58 @@ function getCacheInfo() { } } +// 验证URL格式 +function validateUrl() { + urlError.value = '' + + if (!baseUrlInput.value) { + return + } + + if (!/^https?:\/\/.+\/xiaozhi$/.test(baseUrlInput.value)) { + urlError.value = '请输入有效的服务端地址(以 http 或 https 开头,并以 /xiaozhi 结尾)' + } +} + +// 测试服务端地址 +async function testServerBaseUrl() { + // 先清除错误信息 + urlError.value = '' + + if (!baseUrlInput.value || !/^https?:\/\/.+\/xiaozhi$/.test(baseUrlInput.value)) { + return false + } + + try { + const response = await uni.request({ + url: `${baseUrlInput.value}/api/ping`, + method: 'GET', + timeout: 3000 + }) + + if (response.statusCode === 200) { + return true + } else { + toast.error('无效地址,请检查服务端是否启动或网络连接是否正常') + return false + } + } catch (error) { + console.error('测试服务端地址失败:', error) + toast.error('无效地址,请检查服务端是否启动或网络连接是否正常') + return false + } +} + // 保存服务端地址 -function saveServerBaseUrl() { - if (!baseUrlInput.value || !/^https?:\/\//.test(baseUrlInput.value)) { - toast.warning('请输入有效的服务端地址(以 http 或 https 开头)') +async function saveServerBaseUrl() { + if (!baseUrlInput.value || !/^https?:\/\/.+\/xiaozhi$/.test(baseUrlInput.value)) { + toast.warning('请输入有效的服务端地址(以 http 或 https 开头,并以 /xiaozhi 结尾)') + return + } + + // 测试地址有效性 + const isServerValid = await testServerBaseUrl() + if (!isServerValid) { return } setServerBaseUrlOverride(baseUrlInput.value) @@ -64,20 +113,20 @@ function saveServerBaseUrl() { clearAllCacheAfterUrlChange() uni.showModal({ - title: '重启应用', - content: '服务端地址已保存并清空缓存,是否立即重启生效?', - confirmText: '立即重启', - cancelText: '稍后', - success: (res) => { - if (res.confirm) { - restartApp() - } - else { - toast.success('已保存,可稍后手动重启应用') - } - }, - }) -} + title: '重启应用', + content: '服务端地址已保存并清空缓存,是否立即重启生效?', + confirmText: '立即重启', + cancelText: '稍后', + success: (res) => { + if (res.confirm) { + restartApp() + } + else { + toast.success('已保存,可稍后手动重启应用') + } + }, + }) + } // 重置为 env 默认 function resetServerBaseUrl() { @@ -173,7 +222,7 @@ function showAbout() { title: `关于${import.meta.env.VITE_APP_TITLE}`, content: `${import.meta.env.VITE_APP_TITLE}\n\n基于 Vue.js 3 + uni-app 构建的跨平台移动端管理应用,为小智ESP32智能硬件提供设备管理、智能体配置等功能。\n\n© 2025 xiaozhi-esp32-server`, title: `关于小智智控台`, - content: `小智智控台\n\n基于 Vue.js 3 + uni-app 构建的跨平台移动端管理应用,为小智智控台ESP32智能硬件提供设备管理、智能体配置等功能。\n\n© 2025 xiaozhi-esp32-server 0.7.5`, + content: `小智智控台\n\n基于 Vue.js 3 + uni-app 构建的跨平台移动端管理应用,为小智ESP32智能硬件提供设备管理、智能体配置等功能。\n\n© 2025 xiaozhi-esp32-server 0.7.5`, showCancel: false, confirmText: '确定', }) @@ -201,7 +250,6 @@ onMounted(async () => { - @@ -214,20 +262,22 @@ onMounted(async () => { - + + {{ urlError }} + @@ -320,7 +370,8 @@ onMounted(async () => { - + + From 7f9989511602fef248b63bab5b152e68f7d8e466 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Wed, 20 Aug 2025 14:38:51 +0800 Subject: [PATCH 07/53] Update Deployment_all.md --- docs/Deployment_all.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/Deployment_all.md b/docs/Deployment_all.md index f48f6e10..135b7cf5 100644 --- a/docs/Deployment_all.md +++ b/docs/Deployment_all.md @@ -13,8 +13,7 @@ docker 安装全模块有两种方式,你可以[使用懒人脚本](./Deployme ### 1.1 懒人脚本 -**推荐使用**,**全自动部署**,只需**简单配置**即可使用,视频教程:[https://www.bilibili.com/video/BV17bbvzHExd/](https://www.bilibili.com/video/BV17bbvzHExd/) -你可以使用以下命令一键安装全模块版小智服务端: +部署简便,可以参考[视频教程](https://www.bilibili.com/video/BV17bbvzHExd/) ,文字版教程如下: > [!NOTE] > 暂且只支持Ubuntu服务器一键部署,其他系统未尝试,可能会有一些奇怪的bug From 31f596f96c905661db8457837cb28a2fd040b2dd Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Wed, 20 Aug 2025 14:41:22 +0800 Subject: [PATCH 08/53] Update docker-setup.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 全模块部署,OTA地址是在8002端口 --- docker-setup.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-setup.sh b/docker-setup.sh index 212f68ff..eb91267c 100755 --- a/docker-setup.sh +++ b/docker-setup.sh @@ -409,7 +409,7 @@ LOCAL_IP=$(hostname -I | awk '{print $1}') whiptail --title "安装完成!" --msgbox "\ 服务端相关地址如下:\n\ 管理后台访问地址: http://$LOCAL_IP:8002\n\ -OTA 地址: http://$LOCAL_IP:8003/xiaozhi/ota/\n\ +OTA 地址: http://$LOCAL_IP:8002/xiaozhi/ota/\n\ 视觉分析接口地址: http://$LOCAL_IP:8003/mcp/vision/explain\n\ WebSocket 地址: ws://$LOCAL_IP:8000/xiaozhi/v1/\n\ -\n安装完毕!感谢您的使用!\n按Enter键退出..." 16 70 \ No newline at end of file +\n安装完毕!感谢您的使用!\n按Enter键退出..." 16 70 From 6a79d57a936a8ac0003f9285c6747821e9814756 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Wed, 20 Aug 2025 14:42:46 +0800 Subject: [PATCH 09/53] Update docker-setup.sh --- docker-setup.sh | 2 -- 1 file changed, 2 deletions(-) diff --git a/docker-setup.sh b/docker-setup.sh index eb91267c..8b2f1274 100755 --- a/docker-setup.sh +++ b/docker-setup.sh @@ -402,8 +402,6 @@ fi # 获取并显示地址信息 LOCAL_IP=$(hostname -I | awk '{print $1}') -# WEBSOCKET_ADDR=$(docker logs xiaozhi-esp32-server 2>&1 | tac | grep -m 1 -E -o "ws://[^ ]+") -# VISION_ADDR=$(docker logs xiaozhi-esp32-server 2>&1 | tac | grep -m 1 "视觉" | grep -m 1 -E -o "http://[^ ]+") # 修复日志文件获取不到ws的问题,改为硬编码 whiptail --title "安装完成!" --msgbox "\ From 0ea18d87a304b20ae14f96b8b80a0652885d354e Mon Sep 17 00:00:00 2001 From: Sakura-RanChen <1908198662@qq.com> Date: Wed, 20 Aug 2025 16:22:32 +0800 Subject: [PATCH 10/53] =?UTF-8?q?fix:=20asr=E8=AF=86=E5=88=AB=E7=BC=BA?= =?UTF-8?q?=E5=AD=97=E7=8E=B0=E8=B1=A1=EF=BC=8C=E5=8E=BB=E9=99=A4=E6=97=A0?= =?UTF-8?q?=E7=94=A8=E7=9A=84=E5=8F=98=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/handle/receiveAudioHandle.py | 16 ---------------- main/xiaozhi-server/core/providers/asr/base.py | 4 +--- main/xiaozhi-server/core/providers/vad/silero.py | 4 ++-- 3 files changed, 3 insertions(+), 21 deletions(-) diff --git a/main/xiaozhi-server/core/handle/receiveAudioHandle.py b/main/xiaozhi-server/core/handle/receiveAudioHandle.py index 7d46685b..e5b96be2 100644 --- a/main/xiaozhi-server/core/handle/receiveAudioHandle.py +++ b/main/xiaozhi-server/core/handle/receiveAudioHandle.py @@ -1,5 +1,4 @@ import time -import asyncio import json from core.handle.sendAudioHandle import send_stt_message from core.handle.intentHandler import handle_user_intent @@ -14,14 +13,6 @@ TAG = __name__ async def handleAudioMessage(conn, audio): # 当前片段是否有人说话 have_voice = conn.vad.is_vad(conn, audio) - # 如果设备刚刚被唤醒,短暂忽略VAD检测 - if have_voice and hasattr(conn, "just_woken_up") and conn.just_woken_up: - have_voice = False - # 设置一个短暂延迟后恢复VAD检测 - conn.asr_audio.clear() - if not hasattr(conn, "vad_resume_task") or conn.vad_resume_task.done(): - conn.vad_resume_task = asyncio.create_task(resume_vad_detection(conn)) - return if have_voice: if conn.client_is_speaking: @@ -31,13 +22,6 @@ async def handleAudioMessage(conn, audio): # 接收音频 await conn.asr.receive_audio(conn, audio, have_voice) - -async def resume_vad_detection(conn): - # 等待2秒后恢复VAD检测 - await asyncio.sleep(1) - conn.just_woken_up = False - - async def startToChat(conn, text): # 检查输入是否是JSON格式(包含说话人信息) speaker_name = None diff --git a/main/xiaozhi-server/core/providers/asr/base.py b/main/xiaozhi-server/core/providers/asr/base.py index 972818d4..f4803d31 100644 --- a/main/xiaozhi-server/core/providers/asr/base.py +++ b/main/xiaozhi-server/core/providers/asr/base.py @@ -12,7 +12,7 @@ import time import concurrent.futures from abc import ABC, abstractmethod from config.logger import setup_logging -from typing import Optional, Tuple, List, Dict, Any +from typing import Optional, Tuple, List from core.handle.receiveAudioHandle import startToChat from core.handle.reportHandle import enqueue_asr_report from core.utils.util import remove_punctuation_and_length @@ -132,8 +132,6 @@ class ASRProviderBase(ABC): return None # 使用线程池执行器并行运行 - parallel_start_time = time.monotonic() - with concurrent.futures.ThreadPoolExecutor(max_workers=2) as thread_executor: asr_future = thread_executor.submit(run_asr) diff --git a/main/xiaozhi-server/core/providers/vad/silero.py b/main/xiaozhi-server/core/providers/vad/silero.py index b516d8fb..95ab8ff3 100644 --- a/main/xiaozhi-server/core/providers/vad/silero.py +++ b/main/xiaozhi-server/core/providers/vad/silero.py @@ -33,8 +33,8 @@ class VADProvider(VADProviderBase): int(min_silence_duration_ms) if min_silence_duration_ms else 1000 ) - # 至少要多少帧才算有语音 - self.frame_window_threshold = 3 + # 至少要多少帧才算有语音,增加灵敏度 + self.frame_window_threshold = 1 def is_vad(self, conn, opus_packet): try: From a708382cfd411f2cdb155c717cc0320aee5464db Mon Sep 17 00:00:00 2001 From: FAN-yeB <1442100690@qq.com> Date: Thu, 21 Aug 2025 11:54:06 +0800 Subject: [PATCH 11/53] =?UTF-8?q?update:=E6=9B=B4=E6=96=B0=E6=B5=81?= =?UTF-8?q?=E5=BC=8FASR=E9=A6=96=E8=AF=8D=E7=AD=89=E5=BE=85=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E6=B5=8B=E8=AF=95=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../performance_tester_stream_asr.py | 404 ++++++++++++++++++ 1 file changed, 404 insertions(+) create mode 100644 main/xiaozhi-server/performance_tester/performance_tester_stream_asr.py diff --git a/main/xiaozhi-server/performance_tester/performance_tester_stream_asr.py b/main/xiaozhi-server/performance_tester/performance_tester_stream_asr.py new file mode 100644 index 00000000..e0309685 --- /dev/null +++ b/main/xiaozhi-server/performance_tester/performance_tester_stream_asr.py @@ -0,0 +1,404 @@ +import asyncio +import time +import json +import uuid +import os +import websockets +import gzip +import hmac +import base64 +import hashlib +import random +from urllib import parse +from tabulate import tabulate +from config.settings import load_config +description = "流式ASR首词耗时测试" + +class AccessToken: + @staticmethod + def _encode_text(text): + encoded_text = parse.quote_plus(text) + return encoded_text.replace("+", "%20").replace("*", "%2A").replace("%7E", "~") + + @staticmethod + def _encode_dict(dic): + keys = dic.keys() + dic_sorted = [(key, dic[key]) for key in sorted(keys)] + encoded_text = parse.urlencode(dic_sorted) + return encoded_text.replace("+", "%20").replace("*", "%2A").replace("%7E", "~") + + @staticmethod + def create_token(access_key_id, access_key_secret): + parameters = { + "AccessKeyId": access_key_id, + "Action": "CreateToken", + "Format": "JSON", + "RegionId": "cn-shanghai", + "SignatureMethod": "HMAC-SHA1", + "SignatureNonce": str(uuid.uuid1()), + "SignatureVersion": "1.0", + "Timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "Version": "2019-02-28", + } + query_string = AccessToken._encode_dict(parameters) + string_to_sign = ( + "GET" + "&" + AccessToken._encode_text("/") + "&" + AccessToken._encode_text(query_string) + ) + secreted_string = hmac.new( + bytes(access_key_secret + "&", encoding="utf-8"), + bytes(string_to_sign, encoding="utf-8"), + hashlib.sha1, + ).digest() + signature = base64.b64encode(secreted_string) + signature = AccessToken._encode_text(signature) + full_url = "http://nls-meta.cn-shanghai.aliyuncs.com/?Signature=%s&%s" % (signature, query_string) + response = requests.get(full_url) + if response.ok: + root_obj = response.json() + if "Token" in root_obj: + return root_obj["Token"]["Id"], root_obj["Token"]["ExpireTime"] + return None, None + + +class DoubaoStreamASRPerformanceTester: + def __init__(self): + self.config = load_config() + self.test_audio_files = self._load_test_audio_files() + self.results = [] + + def _load_test_audio_files(self): + """加载测试用的音频文件""" + audio_root = os.path.join(os.getcwd(), "config", "assets") + test_files = [] + + if os.path.exists(audio_root): + for file_name in os.listdir(audio_root): + if file_name.endswith('.wav') or file_name.endswith('.pcm'): + with open(os.path.join(audio_root, file_name), 'rb') as f: + test_files.append(f.read()) + return test_files + + async def test_doubao_stream_asr(self, test_count=5): + """测试豆包流式ASR首词响应时间""" + if not self.test_audio_files: + print("没有找到测试音频文件") + return + + asr_config = self.config["ASR"]["DoubaoStreamASR"] + latencies = [] + + for i in range(test_count): + try: + ws_url = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel" + appid = asr_config["appid"] + access_token = asr_config["access_token"] + uid = asr_config.get("uid", "streaming_asr_service") + + start_time = time.time() + + headers = { + "X-Api-App-Key": appid, + "X-Api-Access-Key": access_token, + "X-Api-Resource-Id": "volc.bigasr.sauc.duration", + "X-Api-Connect-Id": str(uuid.uuid4()) + } + + async with websockets.connect( + ws_url, + additional_headers=headers, + max_size=1000000000, + ping_interval=None, + ping_timeout=None, + close_timeout=10 + ) as ws: + # 发送初始化请求 + request_params = { + "app": { + "appid": appid, + "token": access_token + }, + "user": {"uid": uid}, + "request": { + "reqid": str(uuid.uuid4()), + "workflow": "audio_in,resample,partition,vad,fe,decode,itn,nlu_punctuate", + "show_utterances": True, + "result_type": "single", + "sequence": 1 + }, + "audio": { + "format": "pcm", + "codec": "pcm", + "rate": 16000, + "language": "zh-CN", + "bits": 16, + "channel": 1, + "sample_rate": 16000 + } + } + + payload_bytes = str.encode(json.dumps(request_params)) + payload_bytes = gzip.compress(payload_bytes) + full_client_request = self._generate_header() + full_client_request.extend((len(payload_bytes)).to_bytes(4, "big")) + full_client_request.extend(payload_bytes) + + await ws.send(full_client_request) + + init_res = await ws.recv() + result = self._parse_response(init_res) + + if "code" in result and result["code"] != 1000: + raise Exception(f"ASR服务初始化失败: {result.get('payload_msg', {}).get('error', '未知错误')}") + + # 发送音频数据 + audio_data = self.test_audio_files[0] + if audio_data.startswith(b'RIFF'): + audio_data = audio_data[44:] + + # 直接发送原始音频数据,不进行opus解码 + payload = gzip.compress(audio_data) + audio_request = bytearray(self._generate_audio_default_header()) + audio_request.extend(len(payload).to_bytes(4, "big")) + audio_request.extend(payload) + await ws.send(audio_request) + + # 等待第一个数据块 + first_chunk = await ws.recv() + latency = time.time() - start_time + latencies.append(latency) + await ws.close() + + except Exception as e: + print(f"第{i+1}次测试: {str(e)}") + latencies.append(0) + + return self._calculate_result("豆包流式ASR", latencies, test_count) + + async def test_aliyun_stream_asr(self, test_count=5): + """测试阿里云流式ASR首词响应时间""" + if not self.test_audio_files: + print("没有找到测试音频文件") + return + + asr_config = self.config["ASR"]["AliyunStreamASR"] + latencies = [] + + for i in range(test_count): + try: + access_key_id = asr_config["access_key_id"] + access_key_secret = asr_config["access_key_secret"] + appkey = asr_config["appkey"] + host = asr_config.get("host", "nls-gateway-cn-shanghai.aliyuncs.com") + + # 获取Token + token, _ = AccessToken.create_token(access_key_id, access_key_secret) + if not token: + raise Exception("无法获取阿里云ASR Token") + + # 确定WebSocket URL + if "-internal." in host: + ws_url = f"ws://{host}/ws/v1" + else: + ws_url = f"wss://{host}/ws/v1" + + start_time = time.time() + async with websockets.connect( + ws_url, + additional_headers={"X-NLS-Token": token}, + max_size=1000000000, + ping_interval=None, + ping_timeout=None, + close_timeout=10 + ) as ws: + # 发送开始请求 + start_request = { + "header": { + "namespace": "SpeechTranscriber", + "name": "StartTranscription", + "status": 20000000, + "message_id": ''.join(random.choices('0123456789abcdef', k=32)), + "task_id": ''.join(random.choices('0123456789abcdef', k=32)), + "status_text": "Gateway:SUCCESS:Success.", + "appkey": appkey + }, + "payload": { + "format": "pcm", + "sample_rate": 16000, + "enable_intermediate_result": True, + "enable_punctuation_prediction": True, + "enable_inverse_text_normalization": True, + "max_sentence_silence": asr_config.get("max_sentence_silence", 8000), + "enable_voice_detection": False, + } + } + await ws.send(json.dumps(start_request, ensure_ascii=False)) + + # 等待服务器准备 + start_response = await ws.recv() + response_data = json.loads(start_response) + if response_data["header"]["name"] != "TranscriptionStarted": + raise Exception("阿里云ASR服务初始化失败") + + # 发送音频数据 + audio_data = self.test_audio_files[0] + if audio_data.startswith(b'RIFF'): + audio_data = audio_data[44:] # 去掉WAV头 + + await ws.send(audio_data) + + # 等待第一个结果 + while True: + response = await ws.recv() + if isinstance(response, str): + result = json.loads(response) + if result["header"]["name"] == "TranscriptionResultChanged": + latency = time.time() - start_time + latencies.append(latency) + break + elif result["header"]["name"] == "TaskFailed": + raise Exception(f"阿里云ASR识别失败: {result.get('payload', {}).get('error_info', '未知错误')}") + + # 发送停止请求 + stop_msg = { + "header": { + "namespace": "SpeechTranscriber", + "name": "StopTranscription", + "status": 20000000, + "message_id": ''.join(random.choices('0123456789abcdef', k=32)), + "status_text": "Client:Stop", + "appkey": appkey + } + } + await ws.send(json.dumps(stop_msg, ensure_ascii=False)) + await ws.close() + + except Exception as e: + print(f"第{i+1}次测试: {str(e)}") + latencies.append(0) + + return self._calculate_result("阿里云流式ASR", latencies, test_count) + + def _generate_header(self): + """生成请求头""" + header = bytearray() + header.append((0x01 << 4) | 0x01) + header.append((0x01 << 4) | 0x00) + header.append((0x01 << 4) | 0x01) + header.append(0x00) + return header + + def _generate_audio_default_header(self): + """生成音频请求头""" + return self._generate_header() + + def _parse_response(self, res: bytes) -> dict: + """解析响应""" + try: + if len(res) < 4: + return {"error": "响应数据长度不足"} + + header = res[:4] + message_type = header[1] >> 4 + + if message_type == 0x0F: + code = int.from_bytes(res[4:8], "big", signed=False) + msg_length = int.from_bytes(res[8:12], "big", signed=False) + error_msg = json.loads(res[12:].decode("utf-8")) + return { + "code": code, + "msg_length": msg_length, + "payload_msg": error_msg + } + + try: + json_data = res[12:].decode("utf-8") + return {"payload_msg": json.loads(json_data)} + except (UnicodeDecodeError, json.JSONDecodeError): + return {"error": "JSON解析失败"} + + except Exception: + return {"error": "解析响应失败"} + + def _calculate_result(self, service_name, latencies, test_count): + """计算结果""" + valid_latencies = [l for l in latencies if l > 0] + if valid_latencies: + avg_latency = sum(valid_latencies) / len(valid_latencies) + status = f"成功({len(valid_latencies)}/{test_count}次有效)" + else: + avg_latency = 0 + status = "失败: 所有测试均失败" + return {"name": service_name, "latency": avg_latency, "status": status} + + def _print_results(self, test_count): + """打印测试结果""" + if not self.results: + print("没有有效的ASR测试结果") + return + + print(f"\n{'='*60}") + print("流式ASR首词响应时间测试结果") + print(f"{'='*60}") + print(f"测试次数: 每个ASR服务测试 {test_count} 次") + + # 排序结果:成功优先,按延迟升序 + success_results = sorted( + [r for r in self.results if "成功" in r["status"]], + key=lambda x: x["latency"] + ) + failed_results = [r for r in self.results if "成功" not in r["status"]] + + table_data = [ + [r["name"], f"{r['latency']:.3f}", r["status"]] + for r in success_results + failed_results + ] + + print(tabulate(table_data, headers=["ASR服务", "首词延迟(秒)", "状态"], tablefmt="grid")) + print("\n测试说明:测量从发送请求到接收第一个识别结果的时间,取多次测试平均值") + print("- 超时控制: 单个请求最大等待时间为10秒") + print("- 错误处理: 无法连接和超时的列为网络错误") + print("- 排序规则: 按平均耗时从快到慢排序") + + async def run(self, test_count=5): + """执行测试""" + print(f"开始流式ASR首词响应时间测试...") + print(f"每个ASR服务测试次数: {test_count}次") + + if not self.config.get("ASR"): + print("配置文件中未找到ASR配置") + return + + # 测试每种ASR服务 + self.results = [] + + # 测试豆包ASR + if self.config["ASR"].get("DoubaoStreamASR"): + result = await self.test_doubao_stream_asr(test_count) + self.results.append(result) + else: + print("配置文件中未找到豆包流式ASR配置,跳过测试") + + # 测试阿里云ASR + if self.config["ASR"].get("AliyunStreamASR"): + result = await self.test_aliyun_stream_asr(test_count) + self.results.append(result) + else: + print("配置文件中未找到阿里云流式ASR配置,跳过测试") + + # 打印结果 + self._print_results(test_count) + +async def main(): + import argparse + + parser = argparse.ArgumentParser(description="流式ASR首词响应时间测试工具") + parser.add_argument("--count", type=int, default=5, help="测试次数") + + args = parser.parse_args() + await DoubaoStreamASRPerformanceTester().run(args.count) + +if __name__ == "__main__": + import os + import gzip + import opuslib_next + asyncio.run(main()) \ No newline at end of file From 1988bced6068137e4179ac87b7a273eb4296cbdf Mon Sep 17 00:00:00 2001 From: Sakura-RanChen <1908198662@qq.com> Date: Fri, 22 Aug 2025 17:10:36 +0800 Subject: [PATCH 12/53] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E6=B5=81?= =?UTF-8?q?=E6=8E=A7=E9=9F=B3=E9=A2=91=E6=92=AD=E6=94=BE=20wechat=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E6=A8=A1=E5=BC=8F=E9=94=99=E8=AF=AFSTT=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E5=8F=91=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/handle/sendAudioHandle.py | 56 ++++- .../core/providers/tts/aliyun_stream.py | 1 - .../xiaozhi-server/core/providers/tts/base.py | 92 ++------ .../providers/tts/huoshan_double_stream.py | 1 - .../core/providers/tts/index_stream.py | 1 - .../core/providers/tts/linkerai.py | 1 - .../core/utils/audio_flow_control.py | 201 ------------------ .../plugins_func/functions/play_music.py | 1 - 8 files changed, 67 insertions(+), 287 deletions(-) delete mode 100644 main/xiaozhi-server/core/utils/audio_flow_control.py diff --git a/main/xiaozhi-server/core/handle/sendAudioHandle.py b/main/xiaozhi-server/core/handle/sendAudioHandle.py index bb85c007..8604279a 100644 --- a/main/xiaozhi-server/core/handle/sendAudioHandle.py +++ b/main/xiaozhi-server/core/handle/sendAudioHandle.py @@ -1,5 +1,6 @@ import json import asyncio +import time from core.providers.tts.dto.dto import SentenceType from core.utils import textUtils @@ -29,13 +30,60 @@ async def sendAudioMessage(conn, sentenceType, audios, text): # 播放音频 -async def sendAudio(conn, audios): +async def sendAudio(conn, audios, pre_buffer=False): + """ + 发送单个opus包,支持流控 + Args: + conn: 连接对象 + opus_packet: 单个opus数据包 + pre_buffer: 快速发送音频 + """ if audios is None: return - # 如果audios不是opus数组,则不需要进行遍历,可以直接发送;这里需要进行流控管理,防止发送过快引发客户端溢出 + if isinstance(audios, bytes): + if conn.client_abort: + return + + # 短音频直接发送(例如:提示音) + if pre_buffer: + await conn.websocket.send(audios) + return + + # 重置没有声音的状态 + conn.last_activity_time = time.time() * 1000 + + # 流控逻辑:确保按60ms的帧时长间隔发送 + frame_duration = 60 # 毫秒 + + # 获取或初始化流控状态 + if not hasattr(conn, "audio_flow_control"): + conn.audio_flow_control = { + "last_send_time": 0, + "packet_count": 0, + "start_time": time.perf_counter(), + } + + flow_control = conn.audio_flow_control + current_time = time.perf_counter() + + # 计算期望的发送时间 + expected_time = flow_control["start_time"] + ( + flow_control["packet_count"] * frame_duration / 1000 + ) + + # 流控延迟 + delay = expected_time - current_time + if delay > 0: + await asyncio.sleep(delay) + + # 发送数据包 await conn.websocket.send(audios) + # 更新流控状态 + flow_control["packet_count"] += 1 + flow_control["last_send_time"] = time.perf_counter() + async def send_tts_message(conn, state, text=None): """发送 TTS 状态消息""" @@ -56,7 +104,7 @@ async def send_tts_message(conn, state, text=None): conn.tts.audio_to_opus_data_stream( stop_tts_notify_voice, callback=lambda audio_data: asyncio.run_coroutine_threadsafe( - sendAudio(conn, audio_data), conn.loop + sendAudio(conn, audio_data, True), conn.loop ), ) # 清除服务端讲话状态 @@ -77,7 +125,7 @@ async def send_stt_message(conn, text): display_text = text try: # 尝试解析JSON格式 - if text.strip().startswith('{') and text.strip().endswith('}'): + if text.strip().startswith("{") and text.strip().endswith("}"): parsed_data = json.loads(text) if isinstance(parsed_data, dict) and "content" in parsed_data: # 如果是包含说话人信息的JSON格式,只显示content部分 diff --git a/main/xiaozhi-server/core/providers/tts/aliyun_stream.py b/main/xiaozhi-server/core/providers/tts/aliyun_stream.py index 32e4d50f..734681e2 100644 --- a/main/xiaozhi-server/core/providers/tts/aliyun_stream.py +++ b/main/xiaozhi-server/core/providers/tts/aliyun_stream.py @@ -216,7 +216,6 @@ class TTSProvider(TTSProviderBase): if message.sentence_type == SentenceType.FIRST: self.conn.client_abort = False - self.reset_flow_controller() if self.conn.client_abort: logger.bind(tag=TAG).info("收到打断信息,终止TTS文本处理线程") diff --git a/main/xiaozhi-server/core/providers/tts/base.py b/main/xiaozhi-server/core/providers/tts/base.py index 35d80fb6..6a29f1f5 100644 --- a/main/xiaozhi-server/core/providers/tts/base.py +++ b/main/xiaozhi-server/core/providers/tts/base.py @@ -11,7 +11,6 @@ from datetime import datetime from core.utils import textUtils from abc import ABC, abstractmethod from config.logger import setup_logging -from core.utils.audio_flow_control import FlowControlConfig, simulate_device_consumption from core.utils.util import audio_bytes_to_data_stream, audio_to_data_stream from core.utils.tts import MarkdownCleaner from core.utils.output_counter import add_device_output @@ -71,7 +70,6 @@ class TTSProviderBase(ABC): self.tts_stop_request = False self.processed_chars = 0 self.is_first_sentence = True - self.flow_controller = FlowControlConfig.create_flow_controller() def generate_filename(self, extension=".wav"): return os.path.join( @@ -225,7 +223,6 @@ class TTSProviderBase(ABC): self.tts_text_buff = [] self.is_first_sentence = True self.tts_audio_first_sentence = True - self.reset_flow_controller() elif ContentType.TEXT == message.content_type: self.tts_text_buff.append(message.content_detail) segment_text = self._get_segment_text() @@ -270,12 +267,19 @@ class TTSProviderBase(ABC): if self.conn.client_abort: logger.bind(tag=TAG).debug("收到打断信号,跳过当前音频数据") - # 打断时丢弃未上报的音频数据 enqueue_text, enqueue_audio = None, [] continue # 收到下一个文本开始或会话结束时进行上报 if sentence_type is not SentenceType.MIDDLE: + # 重置音频流控状态(新句子开始或者结束) + if hasattr(self.conn, 'audio_flow_control'): + self.conn.audio_flow_control = { + 'last_send_time': 0, + 'packet_count': 0, + 'start_time': time.perf_counter() + } + # 上报TTS数据 if enqueue_text is not None and enqueue_audio is not None: enqueue_tts_report(self.conn, enqueue_text, enqueue_audio) @@ -284,88 +288,22 @@ class TTSProviderBase(ABC): # 计算音频数据的帧数 if isinstance(audio_datas, bytes): - frame_count = 1 # 单个字节流作为一帧 enqueue_audio.append(audio_datas) - else: - frame_count = 0 + + # 发送音频 + future = asyncio.run_coroutine_threadsafe( + sendAudioMessage(self.conn, sentence_type, audio_datas, text), + self.conn.loop, + ) + future.result() # 记录输出和报告 if self.conn.max_output_size > 0 and text: add_device_output(self.conn.headers.get("device-id"), len(text)) - # 流控检查 - if frame_count > 0: - max_wait_time = FlowControlConfig.DEFAULT_MAX_WAIT_TIME - wait_start_time = time.time() - retry_interval = FlowControlConfig.DEFAULT_RETRY_INTERVAL - - while not self.flow_controller.can_send_frames(frame_count): - # 检查是否超时或需要停止 - if ( - time.time() - wait_start_time > max_wait_time - or self.conn.stop_event.is_set() - or self.conn.client_abort - ): - logger.bind(tag=TAG).debug( - "流控等待超时或收到停止信号,跳过音频发送" - ) - break - # 短暂等待后重试 - time.sleep(retry_interval) - else: - # 可以发送,记录发送的帧数 - self.flow_controller.record_sent_frames(frame_count) - - # 发送音频 - future = asyncio.run_coroutine_threadsafe( - self._send_audio_with_flow_control( - sentence_type, audio_datas, text - ), - self.conn.loop, - ) - future.result() - - # 输出流控状态(调试用) - # status = self.flow_controller.get_status() - # logger.bind(tag=TAG).debug( - # f"流控状态: 缓冲区使用率={status['buffer_usage_percent']:.1f}%, " - # f"可用令牌={status['available_tokens']}..." - # f"发送帧数={status['sent_frames']}..." - # f"消费帧数={status['consumed_frames']}..." - # f"代播放帧数={status['sent_frames'] - status['consumed_frames']}..." - # ) - else: - # 没有音频数据,直接发送 - future = asyncio.run_coroutine_threadsafe( - self._send_audio_with_flow_control( - sentence_type, audio_datas, text - ), - self.conn.loop, - ) - future.result() - except Exception as e: logger.bind(tag=TAG).error(f"audio_play_priority_thread: {text} {e}") - async def _send_audio_with_flow_control(self, sentence_type, audio_datas, text): - """ - 带流控的音频发送方法 模拟设备消费音频帧的过程 - 实际应用中应该根据设备反馈来更新消费情况 - """ - await sendAudioMessage(self.conn, sentence_type, audio_datas, text) - - # 模拟设备消费(实际应用中应该从设备获取反馈)防止音字不同步 - if isinstance(audio_datas, bytes): - frame_count = 1 - asyncio.create_task(simulate_device_consumption(self.flow_controller, frame_count)) - - # 在类中添加流控制器重置方法 - def reset_flow_controller(self): - """重置流控制器状态,通常在新会话开始时调用""" - if hasattr(self, "flow_controller"): - self.flow_controller.reset() - logger.bind(tag=TAG).info("流控制器状态已重置") - async def start_session(self, session_id): pass diff --git a/main/xiaozhi-server/core/providers/tts/huoshan_double_stream.py b/main/xiaozhi-server/core/providers/tts/huoshan_double_stream.py index ac6301f9..463c16fc 100644 --- a/main/xiaozhi-server/core/providers/tts/huoshan_double_stream.py +++ b/main/xiaozhi-server/core/providers/tts/huoshan_double_stream.py @@ -213,7 +213,6 @@ class TTSProvider(TTSProviderBase): if message.sentence_type == SentenceType.FIRST: self.conn.client_abort = False - self.reset_flow_controller() if self.conn.client_abort: try: diff --git a/main/xiaozhi-server/core/providers/tts/index_stream.py b/main/xiaozhi-server/core/providers/tts/index_stream.py index d562e802..6f6b829c 100644 --- a/main/xiaozhi-server/core/providers/tts/index_stream.py +++ b/main/xiaozhi-server/core/providers/tts/index_stream.py @@ -45,7 +45,6 @@ class TTSProvider(TTSProviderBase): self.processed_chars = 0 self.tts_text_buff = [] self.before_stop_play_files.clear() - self.reset_flow_controller() elif ContentType.TEXT == message.content_type: self.tts_text_buff.append(message.content_detail) segment_text = self._get_segment_text() diff --git a/main/xiaozhi-server/core/providers/tts/linkerai.py b/main/xiaozhi-server/core/providers/tts/linkerai.py index a855e8ed..875ed0b1 100644 --- a/main/xiaozhi-server/core/providers/tts/linkerai.py +++ b/main/xiaozhi-server/core/providers/tts/linkerai.py @@ -42,7 +42,6 @@ class TTSProvider(TTSProviderBase): self.processed_chars = 0 self.tts_text_buff = [] self.before_stop_play_files.clear() - self.reset_flow_controller() elif ContentType.TEXT == message.content_type: self.tts_text_buff.append(message.content_detail) segment_text = self._get_segment_text() diff --git a/main/xiaozhi-server/core/utils/audio_flow_control.py b/main/xiaozhi-server/core/utils/audio_flow_control.py deleted file mode 100644 index 958e68a7..00000000 --- a/main/xiaozhi-server/core/utils/audio_flow_control.py +++ /dev/null @@ -1,201 +0,0 @@ -""" -音频流控模块 -包含令牌桶算法和音频流控制器的实现 -""" - -import asyncio -import time -import threading -from collections import deque -from typing import Optional, Dict, Any - - -class TokenBucket: - """令牌桶实现,用于限流控制""" - - def __init__(self, capacity: int, refill_rate: float, initial_tokens: Optional[int] = None): - """ - 初始化令牌桶 - - Args: - capacity: 桶容量(最大令牌数) - refill_rate: 令牌补充速率(每秒补充的令牌数) - initial_tokens: 初始令牌数,默认为桶容量 - """ - self.capacity = capacity - self.refill_rate = refill_rate - self.tokens = initial_tokens if initial_tokens is not None else capacity - self.last_refill_time = time.time() - self.lock = threading.Lock() - - def get_tokens(self, requested_tokens: int = 1) -> bool: - """ - 获取指定数量的令牌 - - Args: - requested_tokens: 请求的令牌数量 - - Returns: - bool: 是否成功获取到令牌 - """ - with self.lock: - self._refill_tokens() - - if self.tokens >= requested_tokens: - self.tokens -= requested_tokens - return True - else: - return False - - def get_available_tokens(self) -> int: - """获取当前可用令牌数""" - with self.lock: - self._refill_tokens() - return int(self.tokens) - - def _refill_tokens(self): - """内部方法:补充令牌""" - current_time = time.time() - time_passed = current_time - self.last_refill_time - tokens_to_add = time_passed * self.refill_rate - - self.tokens = min(self.capacity, self.tokens + tokens_to_add) - self.last_refill_time = current_time - - -class AudioFlowController: - """音频流控制器,基于令牌桶算法控制音频数据发送""" - - def __init__(self, max_device_buffer: int = 3000, refill_rate: float = 20): - """ - 初始化音频流控制器 - - Args: - max_device_buffer: 设备端最大缓冲区大小(Opus帧数) - refill_rate: 令牌补充速率(每秒允许发送的帧数) - """ - self.max_device_buffer = max_device_buffer - self.token_bucket = TokenBucket( - capacity=max_device_buffer, - refill_rate=refill_rate, - initial_tokens=max_device_buffer // 2 # 初始令牌为容量的一半 - ) - self.sent_frames_count = 0 # 已发送帧数计数 - self.device_consumed_frames = 0 # 设备端已消费帧数 - self.pending_queue = deque() # 等待发送的数据队列 - self._lock = threading.Lock() - - def can_send_frames(self, frame_count: int) -> bool: - """ - 检查是否可以发送指定数量的帧 - - Args: - frame_count: 要发送的帧数 - - Returns: - bool: 是否可以发送 - """ - with self._lock: - # 检查设备端缓冲区是否会溢出 - estimated_device_buffer = self.sent_frames_count - self.device_consumed_frames - if estimated_device_buffer + frame_count > self.max_device_buffer: - return False - - # 检查令牌桶是否有足够令牌 - return self.token_bucket.get_tokens(frame_count) - - def update_device_consumption(self, consumed_frames: int): - """ - 更新设备端消费的帧数 - - Args: - consumed_frames: 设备端消费的帧数 - """ - with self._lock: - self.device_consumed_frames += consumed_frames - - def record_sent_frames(self, frame_count: int): - """ - 记录已发送的帧数 - - Args: - frame_count: 发送的帧数 - """ - with self._lock: - self.sent_frames_count += frame_count - - def get_status(self) -> Dict[str, Any]: - """获取流控状态信息""" - with self._lock: - estimated_buffer = self.sent_frames_count - self.device_consumed_frames - return { - "sent_frames": self.sent_frames_count, - "consumed_frames": self.device_consumed_frames, - "estimated_device_buffer": estimated_buffer, - "available_tokens": self.token_bucket.get_available_tokens(), - "pending_queue_size": len(self.pending_queue), - "buffer_usage_percent": (estimated_buffer / self.max_device_buffer) * 100 - } - - def reset(self): - """重置流控状态""" - with self._lock: - self.sent_frames_count = 0 - self.device_consumed_frames = 0 - self.pending_queue.clear() - # 重新初始化令牌桶 - self.token_bucket = TokenBucket( - capacity=self.max_device_buffer, - refill_rate=self.token_bucket.refill_rate, - initial_tokens=self.max_device_buffer // 2 - ) - - -async def simulate_device_consumption( - flow_controller: AudioFlowController, frame_count: int -): - """ - 模拟设备消费音频帧的过程 - 实际应用中应该根据设备反馈来更新消费情况 - Args: - flow_controller: 流控制器实例 - frame_count: 消费的帧数 - """ - # 模拟设备播放延迟(60ms per frame) - await asyncio.sleep(frame_count * 0.06) - flow_controller.update_device_consumption(frame_count) - - -# 流控配置常量 -class FlowControlConfig: - """流控配置常量""" - # Opus 编码参数 - OPUS_FRAME_DURATION_MS = 60 # Opus帧时长(毫秒) - OPUS_FRAMES_PER_SECOND = 1000 / OPUS_FRAME_DURATION_MS # 每秒帧数 - - # 默认流控参数 - DEFAULT_MAX_DEVICE_BUFFER = 40 # 设备端最大缓冲帧数 - DEFAULT_REFILL_RATE = OPUS_FRAMES_PER_SECOND # 默认令牌补充速率(帧/秒) - DEFAULT_MAX_WAIT_TIME = 5.0 # 流控最大等待时间(秒) - DEFAULT_RETRY_INTERVAL = 0.06 # 流控重试间隔(秒) - - # 预缓冲参数 - PRE_BUFFER_FRAMES = 3 # 预缓冲帧数 - - @classmethod - def create_flow_controller(cls, max_buffer: Optional[int] = None, - refill_rate: Optional[float] = None) -> AudioFlowController: - """ - 创建流控制器的工厂方法 - - Args: - max_buffer: 最大缓冲区大小,使用默认值如果为None - refill_rate: 令牌补充速率,使用默认值如果为None - - Returns: - AudioFlowController: 配置好的流控制器实例 - """ - return AudioFlowController( - max_device_buffer=max_buffer or cls.DEFAULT_MAX_DEVICE_BUFFER, - refill_rate=refill_rate or cls.DEFAULT_REFILL_RATE - ) diff --git a/main/xiaozhi-server/plugins_func/functions/play_music.py b/main/xiaozhi-server/plugins_func/functions/play_music.py index 2cbc4018..dd967d0a 100644 --- a/main/xiaozhi-server/plugins_func/functions/play_music.py +++ b/main/xiaozhi-server/plugins_func/functions/play_music.py @@ -212,7 +212,6 @@ async def play_local_music(conn, specific_file=None): conn.logger.bind(tag=TAG).error(f"选定的音乐文件不存在: {music_path}") return text = _get_random_play_prompt(selected_music) - await send_stt_message(conn, text) conn.dialogue.put(Message(role="assistant", content=text)) if conn.intent_type == "intent_llm": From d4b75c5d995be734aa644cdfea95b8bae2c7940e Mon Sep 17 00:00:00 2001 From: Minamiyama Date: Sat, 23 Aug 2025 07:47:40 +0800 Subject: [PATCH 13/53] =?UTF-8?q?refactor(=E9=9F=B3=E9=A2=91=E5=A4=84?= =?UTF-8?q?=E7=90=86):=20=E6=8F=90=E5=8F=96=E9=9F=B3=E9=A2=91=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87=E5=88=9B=E5=BB=BA=E9=80=BB=E8=BE=91=E5=88=B0?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将重复的音频上下文创建逻辑提取到独立的getAudioContextInstance函数中,减少代码重复并统一音频上下文配置 --- main/xiaozhi-server/test/test_page.html | 36 ++++++++++--------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/main/xiaozhi-server/test/test_page.html b/main/xiaozhi-server/test/test_page.html index 6f8fdba9..983d3b9a 100644 --- a/main/xiaozhi-server/test/test_page.html +++ b/main/xiaozhi-server/test/test_page.html @@ -230,6 +230,16 @@ const conversationDiv = document.getElementById('conversation'); const logContainer = document.getElementById('logContainer'); + function getAudioContextInstance() { + if (!audioContext) { + audioContext = new (window.AudioContext || window.webkitAudioContext)({ + sampleRate: SAMPLE_RATE, + latencyHint: 'interactive' + }); + log('创建音频上下文,采样率: ' + SAMPLE_RATE + 'Hz', 'debug'); + } + return audioContext; + } // 初始化可视化器 function initVisualizer() { @@ -306,12 +316,7 @@ // 确保Opus解码器已初始化 try { // 确保音频上下文存在 - if (!audioContext) { - audioContext = new (window.AudioContext || window.webkitAudioContext)({ - sampleRate: SAMPLE_RATE - }); - log('创建音频上下文,采样率: ' + SAMPLE_RATE + 'Hz', 'debug'); - } + audioContext = getAudioContextInstance(); // 确保解码器已初始化 if (!opusDecoder) { @@ -617,10 +622,7 @@ log('已获取麦克风访问权限', 'success'); // 创建音频上下文 - audioContext = new (window.AudioContext || window.webkitAudioContext)({ - sampleRate: 16000, // 确保采样率与服务器期望的一致 - latencyHint: 'interactive' - }); + audioContext = getAudioContextInstance(); const source = audioContext.createMediaStreamSource(stream); // 获取实际音频轨道设置 @@ -1416,12 +1418,7 @@ // 创建音频处理器 async function createAudioProcessor() { - if (!audioContext) { - audioContext = new (window.AudioContext || window.webkitAudioContext)({ - sampleRate: 16000, - latencyHint: 'interactive' - }); - } + audioContext = getAudioContextInstance(); try { // 检查是否支持AudioWorklet @@ -1616,12 +1613,7 @@ }); // 创建音频上下文和分析器 - if (!audioContext) { - audioContext = new (window.AudioContext || window.webkitAudioContext)({ - sampleRate: 16000, - latencyHint: 'interactive' - }); - } + audioContext = getAudioContextInstance(); // 创建音频处理器 const processorResult = await createAudioProcessor(); From 5b7d613a35b15d7068404be5e712b11132829478 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Sat, 23 Aug 2025 23:25:19 +0800 Subject: [PATCH 14/53] =?UTF-8?q?update:=E6=98=BE=E7=A4=BA=E5=A4=A9?= =?UTF-8?q?=E6=B0=94=E6=8E=A5=E5=8F=A3=E9=94=99=E8=AF=AF=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/plugins_func/functions/get_weather.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/main/xiaozhi-server/plugins_func/functions/get_weather.py b/main/xiaozhi-server/plugins_func/functions/get_weather.py index a3af6c03..38770a3f 100644 --- a/main/xiaozhi-server/plugins_func/functions/get_weather.py +++ b/main/xiaozhi-server/plugins_func/functions/get_weather.py @@ -110,6 +110,11 @@ WEATHER_CODE_MAP = { def fetch_city_info(location, api_key, api_host): url = f"https://{api_host}/geo/v2/city/lookup?key={api_key}&location={location}&lang=zh" response = requests.get(url, headers=HEADERS).json() + if response.get("error") is not None: + logger.bind(tag=TAG).error( + f"获取天气失败,原因:{response.get('error', {}).get('detail')}" + ) + return None return response.get("location", [])[0] if response.get("location") else None From 18acec1a8144c535b6435aaceca65420da1ba27c Mon Sep 17 00:00:00 2001 From: Minamiyama Date: Sun, 24 Aug 2025 11:31:30 +0800 Subject: [PATCH 15/53] =?UTF-8?q?refactor(audio):=20=E5=B0=86=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E9=9F=B3=E9=A2=91=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E6=8F=90=E5=8F=96=E4=B8=BA=E7=8B=AC=E7=AB=8B=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将原本内联在test_page.html中的流式音频处理逻辑重构为独立的StreamingContext类,提高代码可维护性和复用性 --- .../test/js/StreamingContext.js | 149 ++++++++++++++++++ main/xiaozhi-server/test/test_page.html | 131 +-------------- 2 files changed, 152 insertions(+), 128 deletions(-) create mode 100644 main/xiaozhi-server/test/js/StreamingContext.js diff --git a/main/xiaozhi-server/test/js/StreamingContext.js b/main/xiaozhi-server/test/js/StreamingContext.js new file mode 100644 index 00000000..576d9761 --- /dev/null +++ b/main/xiaozhi-server/test/js/StreamingContext.js @@ -0,0 +1,149 @@ +import BlockingQueue from './utils/BlockingQueue.js'; +import { log } from './utils/logger.js'; + +// 音频流播放上下文类 +export class StreamingContext { + constructor(opusDecoder, audioContext, sampleRate, channels, minAudioDuration) { + this.opusDecoder = opusDecoder; + this.audioContext = audioContext; + + // 音频参数 + this.sampleRate = sampleRate; + this.channels = channels; + this.minAudioDuration = minAudioDuration; + + // 初始化队列和状态 + this.queue = []; // 已解码的PCM队列。正在播放 + this.activeQueue = new BlockingQueue(); // 已解码的PCM队列。准备播放 + this.pendingAudioBufferQueue = []; // 待处理的缓存队列 + this.audioBufferQueue = new BlockingQueue(); // 缓存队列 + this.playing = false; // 是否正在播放 + this.endOfStream = false; // 是否收到结束信号 + this.source = null; // 当前音频源 + this.totalSamples = 0; // 累积的总样本数 + this.lastPlayTime = 0; // 上次播放的时间戳 + } + + // 缓存音频数组 + pushAudioBuffer(item) { + this.audioBufferQueue.enqueue(...item); + } + + // 获取需要处理缓存队列,单线程:在audioBufferQueue一直更新的状态下不会出现安全问题 + async getPendingAudioBufferQueue() { + // 原子交换 + 清空 + [this.pendingAudioBufferQueue, this.audioBufferQueue] = [await this.audioBufferQueue.dequeue(), new BlockingQueue()]; + } + + // 获取正在播放已解码的PCM队列,单线程:在activeQueue一直更新的状态下不会出现安全问题 + async getQueue(minSamples) { + let TepArray = []; + const num = minSamples - this.queue.length > 0 ? minSamples - this.queue.length : 1; + // 原子交换 + 清空 + [TepArray, this.activeQueue] = [await this.activeQueue.dequeue(num), new BlockingQueue()]; + this.queue.push(...TepArray); + } + + // 将Int16音频数据转换为Float32音频数据 + convertInt16ToFloat32(int16Data) { + const float32Data = new Float32Array(int16Data.length); + for (let i = 0; i < int16Data.length; i++) { + // 将[-32768,32767]范围转换为[-1,1] + float32Data[i] = int16Data[i] / (int16Data[i] < 0 ? 0x8000 : 0x7FFF); + } + return float32Data; + } + + // 将Opus数据解码为PCM + async decodeOpusFrames() { + if (!this.opusDecoder) { + log('Opus解码器未初始化,无法解码', 'error'); + return; + } else { + log('Opus解码器启动', 'info'); + } + + while (true) { + let decodedSamples = []; + for (const frame of this.pendingAudioBufferQueue) { + try { + // 使用Opus解码器解码 + const frameData = this.opusDecoder.decode(frame); + if (frameData && frameData.length > 0) { + // 转换为Float32 + const floatData = this.convertInt16ToFloat32(frameData); + // 使用循环替代展开运算符 + for (let i = 0; i < floatData.length; i++) { + decodedSamples.push(floatData[i]); + } + } + } catch (error) { + log("Opus解码失败: " + error.message, 'error'); + } + } + + if (decodedSamples.length > 0) { + // 使用循环替代展开运算符 + for (let i = 0; i < decodedSamples.length; i++) { + this.activeQueue.enqueue(decodedSamples[i]); + } + this.totalSamples += decodedSamples.length; + } else { + log('没有成功解码的样本', 'warning'); + } + await this.getPendingAudioBufferQueue(); + } + } + + // 开始播放音频 + async startPlaying() { + while (true) { + // 如果累积了至少0.3秒的音频,开始播放 + const minSamples = this.sampleRate * this.minAudioDuration * 3; + if (!this.playing && this.queue.length < minSamples) { + await this.getQueue(minSamples); + } + this.playing = true; + while (this.playing && this.queue.length) { + // 创建新的音频缓冲区 + const minPlaySamples = Math.min(this.queue.length, this.sampleRate); + const currentSamples = this.queue.splice(0, minPlaySamples); + + const audioBuffer = this.audioContext.createBuffer(this.channels, currentSamples.length, this.sampleRate); + audioBuffer.copyToChannel(new Float32Array(currentSamples), 0); + + // 创建音频源 + this.source = this.audioContext.createBufferSource(); + this.source.buffer = audioBuffer; + + // 创建增益节点用于平滑过渡 + const gainNode = this.audioContext.createGain(); + + // 应用淡入淡出效果避免爆音 + const fadeDuration = 0.02; // 20毫秒 + gainNode.gain.setValueAtTime(0, this.audioContext.currentTime); + gainNode.gain.linearRampToValueAtTime(1, this.audioContext.currentTime + fadeDuration); + + const duration = audioBuffer.duration; + if (duration > fadeDuration * 2) { + gainNode.gain.setValueAtTime(1, this.audioContext.currentTime + duration - fadeDuration); + gainNode.gain.linearRampToValueAtTime(0, this.audioContext.currentTime + duration); + } + + // 连接节点并开始播放 + this.source.connect(gainNode); + gainNode.connect(this.audioContext.destination); + + this.lastPlayTime = this.audioContext.currentTime; + log(`开始播放 ${currentSamples.length} 个样本,约 ${(currentSamples.length / this.sampleRate).toFixed(2)} 秒`, 'info'); + this.source.start(); + } + await this.getQueue(minSamples); + } + } +} + +// 创建streamingContext实例的工厂函数 +export function createStreamingContext(opusDecoder, audioContext, sampleRate, channels, minAudioDuration) { + return new StreamingContext(opusDecoder, audioContext, sampleRate, channels, minAudioDuration); +} \ No newline at end of file diff --git a/main/xiaozhi-server/test/test_page.html b/main/xiaozhi-server/test/test_page.html index 983d3b9a..547f2b31 100644 --- a/main/xiaozhi-server/test/test_page.html +++ b/main/xiaozhi-server/test/test_page.html @@ -181,6 +181,7 @@ import { checkOpusLoaded, initOpusEncoder } from './js/opus.js'; import { addMessage } from './js/document.js' import BlockingQueue from './js/utils/BlockingQueue.js' + import { createStreamingContext } from './js/StreamingContext.js' // 需要加载的脚本列表 - 移除Opus依赖 const scriptFiles = []; @@ -336,125 +337,7 @@ // 创建流式播放上下文 if (!streamingContext) { - streamingContext = { - queue: [], // 已解码的PCM队列。正在播放 - activeQueue: new BlockingQueue(), // 已解码的PCM队列。准备播放 - pendingAudioBufferQueue: [], // 待处理的缓存队列 - audioBufferQueue: new BlockingQueue(), // 缓存队列 - playing: false, // 是否正在播放 - endOfStream: false, // 是否收到结束信号 - source: null, // 当前音频源 - totalSamples: 0, // 累积的总样本数 - lastPlayTime: 0, // 上次播放的时间戳 - - - // 缓存音频数组 - pushAudioBuffer: function (item) { - this.audioBufferQueue.enqueue(...item) - }, - - // 获取需要处理缓存队列,单线程:在audioBufferQueue一直更新的状态下不会出现安全问题 - getPendingAudioBufferQueue: async function () { - // 原子交换 + 清空 - [this.pendingAudioBufferQueue, this.audioBufferQueue] = [await this.audioBufferQueue.dequeue(), new BlockingQueue()]; - - }, - // 获取正在播放已解码的PCM队列,单线程:在activeQueue一直更新的状态下不会出现安全问题 - getQueue: async function (minSamples) { - let TepArray = [] - const num = minSamples - this.queue.length > 0 ? minSamples - this.queue.length : 1; - // 原子交换 + 清空 - [TepArray, this.activeQueue] = [await this.activeQueue.dequeue(num), new BlockingQueue()]; - this.queue.push(...TepArray) - }, - // 将Opus数据解码为PCM - decodeOpusFrames: async function () { - if (!opusDecoder) { - log('Opus解码器未初始化,无法解码', 'error'); - return; - } else { - log('Opus解码器启动', 'info'); - } - - while (true) { - let decodedSamples = []; - for (const frame of this.pendingAudioBufferQueue) { - try { - // 使用Opus解码器解码 - const frameData = opusDecoder.decode(frame); - if (frameData && frameData.length > 0) { - // 转换为Float32 - const floatData = convertInt16ToFloat32(frameData); - // 使用循环替代展开运算符 - for (let i = 0; i < floatData.length; i++) { - decodedSamples.push(floatData[i]); - } - } - } catch (error) { - log("Opus解码失败: " + error.message, 'error'); - } - } - - if (decodedSamples.length > 0) { - // 使用循环替代展开运算符 - for (let i = 0; i < decodedSamples.length; i++) { - this.activeQueue.enqueue(decodedSamples[i]); - } - this.totalSamples += decodedSamples.length; - } else { - log('没有成功解码的样本', 'warning'); - } - await this.getPendingAudioBufferQueue(); - } - }, - - // 开始播放音频 - startPlaying: async function () { - while (true) { - // 如果累积了至少0.3秒的音频,开始播放 - const minSamples = SAMPLE_RATE * MIN_AUDIO_DURATION * 3; - if (!this.playing && this.queue.length < minSamples) { - await this.getQueue(minSamples) - } - this.playing = true; - while (this.playing && this.queue.length) { - // 创建新的音频缓冲区 - const minPlaySamples = Math.min(this.queue.length, SAMPLE_RATE); - const currentSamples = this.queue.splice(0, minPlaySamples); - - const audioBuffer = audioContext.createBuffer(CHANNELS, currentSamples.length, SAMPLE_RATE); - audioBuffer.copyToChannel(new Float32Array(currentSamples), 0); - - // 创建音频源 - this.source = audioContext.createBufferSource(); - this.source.buffer = audioBuffer; - - // 创建增益节点用于平滑过渡 - const gainNode = audioContext.createGain(); - - // 应用淡入淡出效果避免爆音 - const fadeDuration = 0.02; // 20毫秒 - gainNode.gain.setValueAtTime(0, audioContext.currentTime); - gainNode.gain.linearRampToValueAtTime(1, audioContext.currentTime + fadeDuration); - - const duration = audioBuffer.duration; - if (duration > fadeDuration * 2) { - gainNode.gain.setValueAtTime(1, audioContext.currentTime + duration - fadeDuration); - gainNode.gain.linearRampToValueAtTime(0, audioContext.currentTime + duration); - } - - // 连接节点并开始播放 - this.source.connect(gainNode); - gainNode.connect(audioContext.destination); - - this.lastPlayTime = audioContext.currentTime; - log(`开始播放 ${currentSamples.length} 个样本,约 ${(currentSamples.length / SAMPLE_RATE).toFixed(2)} 秒`, 'info'); - this.source.start(); - } - await this.getQueue(minSamples) - } - } - }; + streamingContext = createStreamingContext(opusDecoder, audioContext, SAMPLE_RATE, CHANNELS, MIN_AUDIO_DURATION); } streamingContext.decodeOpusFrames(); @@ -467,15 +350,7 @@ } } - // 将Int16音频数据转换为Float32音频数据 - function convertInt16ToFloat32(int16Data) { - const float32Data = new Float32Array(int16Data.length); - for (let i = 0; i < int16Data.length; i++) { - // 将[-32768,32767]范围转换为[-1,1] - float32Data[i] = int16Data[i] / (int16Data[i] < 0 ? 0x8000 : 0x7FFF); - } - return float32Data; - } + // 初始化Opus解码器 - 确保完全初始化完成后才返回 async function initOpusDecoder() { From 5993470d3f304be545a8a1f5e7ee728dd23e4a05 Mon Sep 17 00:00:00 2001 From: Minamiyama Date: Sun, 24 Aug 2025 23:40:54 +0800 Subject: [PATCH 16/53] =?UTF-8?q?feat(=E6=A8=A1=E5=9E=8B=E7=AE=A1=E7=90=86?= =?UTF-8?q?):=20=E6=B7=BB=E5=8A=A0=E6=A8=A1=E5=9E=8B=E5=89=AF=E6=9C=AC?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在模型编辑对话框中增加副本创建功能,当选择创建副本时自动在原模型名称和代码后添加'_副本'后缀,并通过新增API接口保存为新模型 --- .../src/components/ModelEditDialog.vue | 7 +++- main/manager-web/src/views/ModelConfig.vue | 37 +++++++++++++++---- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/main/manager-web/src/components/ModelEditDialog.vue b/main/manager-web/src/components/ModelEditDialog.vue index 0bc2f7cf..ec29c30e 100644 --- a/main/manager-web/src/components/ModelEditDialog.vue +++ b/main/manager-web/src/components/ModelEditDialog.vue @@ -3,7 +3,7 @@ class="center-dialog" >
- 修改模型 + {{ modelData.duplicateMode ? '创建副本' : '修改模型' }}