mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-28 01:53:53 +08:00
Merge branch 'main' into py_MinmaxStreamTTS_test
This commit is contained in:
@@ -31,6 +31,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
driver-opts: |
|
||||||
|
network=host
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
@@ -60,6 +63,10 @@ jobs:
|
|||||||
tags: |
|
tags: |
|
||||||
${{ env.IS_VERSION == 'true' && format('ghcr.io/{0}:server_{1},ghcr.io/{0}:server_latest', github.repository, env.VERSION) || format('ghcr.io/{0}:server_latest', github.repository) }}
|
${{ env.IS_VERSION == 'true' && format('ghcr.io/{0}:server_{1},ghcr.io/{0}:server_latest', github.repository, env.VERSION) || format('ghcr.io/{0}:server_latest', github.repository) }}
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
build-args: |
|
||||||
|
BUILDKIT_PROGRESS=plain
|
||||||
|
|
||||||
# 构建 manager-api 镜像
|
# 构建 manager-api 镜像
|
||||||
- name: Build and push manager-web
|
- name: Build and push manager-web
|
||||||
@@ -70,4 +77,8 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.IS_VERSION == 'true' && format('ghcr.io/{0}:web_{1},ghcr.io/{0}:web_latest', github.repository, env.VERSION) || format('ghcr.io/{0}:web_latest', github.repository) }}
|
${{ env.IS_VERSION == 'true' && format('ghcr.io/{0}:web_{1},ghcr.io/{0}:web_latest', github.repository, env.VERSION) || format('ghcr.io/{0}:web_latest', github.repository) }}
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
build-args: |
|
||||||
|
BUILDKIT_PROGRESS=plain
|
||||||
+9
-2
@@ -3,10 +3,17 @@ FROM python:3.10-slim AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 配置pip使用国内镜像源(阿里云)并设置超时和重试
|
||||||
|
RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && \
|
||||||
|
pip config set global.trusted-host mirrors.aliyun.com && \
|
||||||
|
pip config set global.timeout 120 && \
|
||||||
|
pip config set install.retries 5
|
||||||
|
|
||||||
COPY main/xiaozhi-server/requirements.txt .
|
COPY main/xiaozhi-server/requirements.txt .
|
||||||
|
|
||||||
# 安装Python依赖
|
# 安装Python依赖,使用并行下载
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir --upgrade pip setuptools wheel && \
|
||||||
|
pip install --no-cache-dir -r requirements.txt --default-timeout=120 --retries 5
|
||||||
|
|
||||||
# 第二阶段:生产镜像
|
# 第二阶段:生产镜像
|
||||||
FROM python:3.10-slim
|
FROM python:3.10-slim
|
||||||
|
|||||||
+3
-3
@@ -18,12 +18,12 @@ FROM bellsoft/liberica-runtime-container:jre-21-glibc
|
|||||||
|
|
||||||
# 安装Nginx和字体库
|
# 安装Nginx和字体库
|
||||||
RUN apk update && \
|
RUN apk update && \
|
||||||
apk add --no-cache nginx bash && \
|
apk add --no-cache nginx bash fontconfig ttf-dejavu && \
|
||||||
apk add --no-cache fontconfig ttf-dejavu msttcorefonts-installer && \
|
apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/testing/ msttcorefonts-installer || true && \
|
||||||
rm -rf /var/cache/apk/*
|
rm -rf /var/cache/apk/*
|
||||||
|
|
||||||
# 更新字体缓存
|
# 更新字体缓存
|
||||||
RUN printf 'YES\n' | update-ms-fonts && fc-cache -f -v
|
RUN (printf 'YES\n' | update-ms-fonts || true) && fc-cache -f -v
|
||||||
|
|
||||||
# 配置Nginx
|
# 配置Nginx
|
||||||
COPY docs/docker/nginx.conf /etc/nginx/nginx.conf
|
COPY docs/docker/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|||||||
@@ -38,9 +38,9 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
By Professor Siyuan Liu Research and Development Group ( South China University of Technology)
|
Spearheaded by Professor Siyuan Liu's Team (South China University of Technology)
|
||||||
</br>
|
</br>
|
||||||
刘思源教授团队研发(华南理工大学)
|
刘思源教授团队主导研发(华南理工大学)
|
||||||
</br>
|
</br>
|
||||||
<img src="./docs/images/hnlg.jpg" alt="华南理工大学" width="50%">
|
<img src="./docs/images/hnlg.jpg" alt="华南理工大学" width="50%">
|
||||||
</p>
|
</p>
|
||||||
@@ -94,7 +94,7 @@ By Professor Siyuan Liu Research and Development Group ( South China University
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a href="https://www.bilibili.com/video/BV1Vy96YCE3R" target="_blank">
|
<a href="https://www.bilibili.com/video/BV1vchQzaEse" target="_blank">
|
||||||
<picture>
|
<picture>
|
||||||
<img alt="自定义音色" src="docs/images/demo6.png" />
|
<img alt="自定义音色" src="docs/images/demo6.png" />
|
||||||
</picture>
|
</picture>
|
||||||
@@ -197,6 +197,7 @@ By Professor Siyuan Liu Research and Development Group ( South China University
|
|||||||
|
|
||||||
```
|
```
|
||||||
智控台地址: https://2662r3426b.vicp.fun
|
智控台地址: https://2662r3426b.vicp.fun
|
||||||
|
智控台(h5版): https://2662r3426b.vicp.fun/h5/index.html
|
||||||
|
|
||||||
服务测试工具: https://2662r3426b.vicp.fun/test/
|
服务测试工具: https://2662r3426b.vicp.fun/test/
|
||||||
OTA接口地址: https://2662r3426b.vicp.fun/xiaozhi/ota/
|
OTA接口地址: https://2662r3426b.vicp.fun/xiaozhi/ota/
|
||||||
@@ -282,6 +283,8 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
|||||||
| dify 接口调用 | Dify | - |
|
| dify 接口调用 | Dify | - |
|
||||||
| fastgpt 接口调用 | Fastgpt | - |
|
| fastgpt 接口调用 | Fastgpt | - |
|
||||||
| coze 接口调用 | Coze | - |
|
| coze 接口调用 | Coze | - |
|
||||||
|
| xinference 接口调用 | Xinference | - |
|
||||||
|
| homeassistant 接口调用 | HomeAssistant | - |
|
||||||
|
|
||||||
实际上,任何支持 openai 接口调用的 LLM 均可接入使用。
|
实际上,任何支持 openai 接口调用的 LLM 均可接入使用。
|
||||||
|
|
||||||
@@ -301,8 +304,8 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
|||||||
|
|
||||||
| 使用方式 | 支持平台 | 免费平台 |
|
| 使用方式 | 支持平台 | 免费平台 |
|
||||||
|:---:|:---:|:---:|
|
|:---:|:---:|:---:|
|
||||||
| 接口调用 | EdgeTTS、火山引擎豆包TTS、腾讯云、阿里云TTS、CosyVoiceSiliconflow、TTS302AI、CozeCnTTS、GizwitsTTS、ACGNTTS、OpenAITTS、灵犀流式TTS | 灵犀流式TTS、EdgeTTS、CosyVoiceSiliconflow(部分) |
|
| 接口调用 | EdgeTTS、火山引擎豆包TTS、腾讯云、阿里云TTS、阿里云流式TTS、CosyVoiceSiliconflow、TTS302AI、CozeCnTTS、GizwitsTTS、ACGNTTS、OpenAITTS、灵犀流式TTS、MinimaxTTS、火山双流式TTS | 灵犀流式TTS、EdgeTTS、CosyVoiceSiliconflow(部分) |
|
||||||
| 本地服务 | FishSpeech、GPT_SOVITS_V2、GPT_SOVITS_V3、MinimaxTTS | FishSpeech、GPT_SOVITS_V2、GPT_SOVITS_V3、MinimaxTTS |
|
| 本地服务 | FishSpeech、GPT_SOVITS_V2、GPT_SOVITS_V3、Index-TTS、PaddleSpeech | Index-TTS、PaddleSpeech、FishSpeech、GPT_SOVITS_V2、GPT_SOVITS_V3 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -319,7 +322,7 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
|||||||
| 使用方式 | 支持平台 | 免费平台 |
|
| 使用方式 | 支持平台 | 免费平台 |
|
||||||
|:---:|:---:|:---:|
|
|:---:|:---:|:---:|
|
||||||
| 本地使用 | FunASR、SherpaASR | FunASR、SherpaASR |
|
| 本地使用 | FunASR、SherpaASR | FunASR、SherpaASR |
|
||||||
| 接口调用 | DoubaoASR、FunASRServer、TencentASR、AliyunASR | FunASRServer |
|
| 接口调用 | DoubaoASR、Doubao流式ASR、FunASRServer、TencentASR、AliyunASR、Aliyun流式ASR、百度ASR、OpenAI ASR | FunASRServer |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -337,6 +340,7 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
|||||||
|:------:|:---------------:|:----:|:---------:|:--:|
|
|:------:|:---------------:|:----:|:---------:|:--:|
|
||||||
| Memory | mem0ai | 接口调用 | 1000次/月额度 | |
|
| Memory | mem0ai | 接口调用 | 1000次/月额度 | |
|
||||||
| Memory | mem_local_short | 本地总结 | 免费 | |
|
| Memory | mem_local_short | 本地总结 | 免费 | |
|
||||||
|
| Memory | nomem | 无记忆模式 | 免费 | |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -346,6 +350,7 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
|||||||
|:------:|:-------------:|:----:|:-------:|:---------------------:|
|
|:------:|:-------------:|:----:|:-------:|:---------------------:|
|
||||||
| Intent | intent_llm | 接口调用 | 根据LLM收费 | 通过大模型识别意图,通用性强 |
|
| Intent | intent_llm | 接口调用 | 根据LLM收费 | 通过大模型识别意图,通用性强 |
|
||||||
| Intent | function_call | 接口调用 | 根据LLM收费 | 通过大模型函数调用完成意图,速度快,效果好 |
|
| Intent | function_call | 接口调用 | 根据LLM收费 | 通过大模型函数调用完成意图,速度快,效果好 |
|
||||||
|
| Intent | nointent | 无意图模式 | 免费 | 不进行意图识别,直接返回对话结果 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -356,8 +361,10 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
|||||||
| <img src="./docs/images/logo_bailing.png" width="160"> | [百聆语音对话机器人](https://github.com/wwbin2017/bailing) | 本项目受[百聆语音对话机器人](https://github.com/wwbin2017/bailing)启发,并在其基础上实现 |
|
| <img src="./docs/images/logo_bailing.png" width="160"> | [百聆语音对话机器人](https://github.com/wwbin2017/bailing) | 本项目受[百聆语音对话机器人](https://github.com/wwbin2017/bailing)启发,并在其基础上实现 |
|
||||||
| <img src="./docs/images/logo_tenclass.png" width="160"> | [十方融海](https://www.tenclass.com/) | 感谢[十方融海](https://www.tenclass.com/)为小智生态制定了标准的通讯协议、多设备兼容性方案及高并发场景实践示范;为本项目提供了全链路技术文档支持 |
|
| <img src="./docs/images/logo_tenclass.png" width="160"> | [十方融海](https://www.tenclass.com/) | 感谢[十方融海](https://www.tenclass.com/)为小智生态制定了标准的通讯协议、多设备兼容性方案及高并发场景实践示范;为本项目提供了全链路技术文档支持 |
|
||||||
| <img src="./docs/images/logo_xuanfeng.png" width="160"> | [玄凤科技](https://github.com/Eric0308) | 感谢[玄凤科技](https://github.com/Eric0308)贡献函数调用框架、MCP通信协议及插件化调用机制的实现代码,通过标准化的指令调度体系与动态扩展能力,显著提升了前端设备(IoT)的交互效率和功能延展性 |
|
| <img src="./docs/images/logo_xuanfeng.png" width="160"> | [玄凤科技](https://github.com/Eric0308) | 感谢[玄凤科技](https://github.com/Eric0308)贡献函数调用框架、MCP通信协议及插件化调用机制的实现代码,通过标准化的指令调度体系与动态扩展能力,显著提升了前端设备(IoT)的交互效率和功能延展性 |
|
||||||
|
| <img src="./docs/images/logo_junsen.png" width="160"> | [huangjunsen](https://github.com/huangjunsen0406) | 感谢[huangjunsen](https://github.com/huangjunsen0406) 贡献`智控台移动端`模块,实现了跨平台移动设备的高效控制与实时交互,大幅提升了系统在移动场景下的操作便捷性和管理效率 |
|
||||||
| <img src="./docs/images/logo_huiyuan.png" width="160"> | [汇远设计](http://ui.kwd988.net/) | 感谢[汇远设计](http://ui.kwd988.net/)为本项目提供专业视觉解决方案,用其服务超千家企业的设计实战经验,赋能本项目产品用户体验 |
|
| <img src="./docs/images/logo_huiyuan.png" width="160"> | [汇远设计](http://ui.kwd988.net/) | 感谢[汇远设计](http://ui.kwd988.net/)为本项目提供专业视觉解决方案,用其服务超千家企业的设计实战经验,赋能本项目产品用户体验 |
|
||||||
| <img src="./docs/images/logo_qinren.png" width="160"> | [西安勤人信息科技](https://www.029app.com/) | 感谢[西安勤人信息科技](https://www.029app.com/)深化本项目视觉体系,确保整体设计风格在多场景应用中的一致性和扩展性 |
|
| <img src="./docs/images/logo_qinren.png" width="160"> | [西安勤人信息科技](https://www.029app.com/) | 感谢[西安勤人信息科技](https://www.029app.com/)深化本项目视觉体系,确保整体设计风格在多场景应用中的一致性和扩展性 |
|
||||||
|
| <img src="./docs/images/logo_contributors.png" width="160"> | [代码贡献者](https://github.com/xinnan-tech/xiaozhi-esp32-server/graphs/contributors) | 感谢[所有代码贡献者](https://github.com/xinnan-tech/xiaozhi-esp32-server/graphs/contributors)贡献者,你们的付出让项目更加健壮和强大。 |
|
||||||
|
|
||||||
|
|
||||||
<a href="https://star-history.com/#xinnan-tech/xiaozhi-esp32-server&Date">
|
<a href="https://star-history.com/#xinnan-tech/xiaozhi-esp32-server&Date">
|
||||||
|
|||||||
+6
-3
@@ -38,9 +38,9 @@ Supports MCP endpoints and voiceprint recognition
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
By Professor Siyuan Liu Research and Development Group (South China University of Technology)
|
Spearheaded by Professor Siyuan Liu's Team (South China University of Technology)
|
||||||
</br>
|
</br>
|
||||||
刘思源教授团队研发(华南理工大学)
|
刘思源教授团队主导研发(华南理工大学)
|
||||||
</br>
|
</br>
|
||||||
<img src="./docs/images/hnlg.jpg" alt="South China University of Technology" width="50%">
|
<img src="./docs/images/hnlg.jpg" alt="South China University of Technology" width="50%">
|
||||||
</p>
|
</p>
|
||||||
@@ -93,7 +93,7 @@ Want to see the usage effects? Click the videos below 🎥
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a href="https://www.bilibili.com/video/BV1Vy96YCE3R" target="_blank">
|
<a href="https://www.bilibili.com/video/BV1vchQzaEse" target="_blank">
|
||||||
<picture>
|
<picture>
|
||||||
<img alt="Custom voice timbre" src="docs/images/demo6.png" />
|
<img alt="Custom voice timbre" src="docs/images/demo6.png" />
|
||||||
</picture>
|
</picture>
|
||||||
@@ -194,6 +194,7 @@ This project provides two deployment methods. Please choose based on your specif
|
|||||||
|
|
||||||
```
|
```
|
||||||
Intelligent Control Console Address: https://2662r3426b.vicp.fun
|
Intelligent Control Console Address: https://2662r3426b.vicp.fun
|
||||||
|
Intelligent Control Console Address (H5): https://2662r3426b.vicp.fun/h5/index.html
|
||||||
|
|
||||||
Service Test Tool: https://2662r3426b.vicp.fun/test/
|
Service Test Tool: https://2662r3426b.vicp.fun/test/
|
||||||
OTA Interface Address: https://2662r3426b.vicp.fun/xiaozhi/ota/
|
OTA Interface Address: https://2662r3426b.vicp.fun/xiaozhi/ota/
|
||||||
@@ -352,8 +353,10 @@ In fact, any VLLM that supports OpenAI interface calls can be integrated and use
|
|||||||
| <img src="./docs/images/logo_bailing.png" width="160"> | [Bailing Voice Dialogue Robot](https://github.com/wwbin2017/bailing) | This project is inspired by [Bailing Voice Dialogue Robot](https://github.com/wwbin2017/bailing) and implemented on its basis |
|
| <img src="./docs/images/logo_bailing.png" width="160"> | [Bailing Voice Dialogue Robot](https://github.com/wwbin2017/bailing) | This project is inspired by [Bailing Voice Dialogue Robot](https://github.com/wwbin2017/bailing) and implemented on its basis |
|
||||||
| <img src="./docs/images/logo_tenclass.png" width="160"> | [Tenclass](https://www.tenclass.com/) | Thanks to [Tenclass](https://www.tenclass.com/) for formulating standard communication protocols, multi-device compatibility solutions, and high-concurrency scenario practice demonstrations for the Xiaozhi ecosystem; providing full-link technical documentation support for this project |
|
| <img src="./docs/images/logo_tenclass.png" width="160"> | [Tenclass](https://www.tenclass.com/) | Thanks to [Tenclass](https://www.tenclass.com/) for formulating standard communication protocols, multi-device compatibility solutions, and high-concurrency scenario practice demonstrations for the Xiaozhi ecosystem; providing full-link technical documentation support for this project |
|
||||||
| <img src="./docs/images/logo_xuanfeng.png" width="160"> | [Xuanfeng Technology](https://github.com/Eric0308) | Thanks to [Xuanfeng Technology](https://github.com/Eric0308) for contributing function calling framework, MCP communication protocol, and plugin-based calling mechanism implementation code. Through standardized instruction scheduling system and dynamic expansion capabilities, it significantly improves the interaction efficiency and functional extensibility of frontend devices (IoT) |
|
| <img src="./docs/images/logo_xuanfeng.png" width="160"> | [Xuanfeng Technology](https://github.com/Eric0308) | Thanks to [Xuanfeng Technology](https://github.com/Eric0308) for contributing function calling framework, MCP communication protocol, and plugin-based calling mechanism implementation code. Through standardized instruction scheduling system and dynamic expansion capabilities, it significantly improves the interaction efficiency and functional extensibility of frontend devices (IoT) |
|
||||||
|
| <img src="./docs/images/logo_junsen.png" width="160"> | [huangjunsen](https://github.com/huangjunsen0406) | Thanks to [huangjunsen](https://github.com/huangjunsen0406) for contributing the `Smart Control Console Mobile` module, which enables efficient control and real-time interaction across mobile devices, significantly enhancing the system's operational convenience and management efficiency in mobile scenarios. |
|
||||||
| <img src="./docs/images/logo_huiyuan.png" width="160"> | [Huiyuan Design](http://ui.kwd988.net/) | Thanks to [Huiyuan Design](http://ui.kwd988.net/) for providing professional visual solutions for this project, using their design practical experience serving over a thousand enterprises to empower this project's product user experience |
|
| <img src="./docs/images/logo_huiyuan.png" width="160"> | [Huiyuan Design](http://ui.kwd988.net/) | Thanks to [Huiyuan Design](http://ui.kwd988.net/) for providing professional visual solutions for this project, using their design practical experience serving over a thousand enterprises to empower this project's product user experience |
|
||||||
| <img src="./docs/images/logo_qinren.png" width="160"> | [Xi'an Qinren Information Technology](https://www.029app.com/) | Thanks to [Xi'an Qinren Information Technology](https://www.029app.com/) for deepening this project's visual system, ensuring consistency and extensibility of overall design style in multi-scenario applications |
|
| <img src="./docs/images/logo_qinren.png" width="160"> | [Xi'an Qinren Information Technology](https://www.029app.com/) | Thanks to [Xi'an Qinren Information Technology](https://www.029app.com/) for deepening this project's visual system, ensuring consistency and extensibility of overall design style in multi-scenario applications |
|
||||||
|
| <img src="./docs/images/logo_contributors.png" width="160"> | [Code Contributors](https://github.com/xinnan-tech/xiaozhi-esp32-server/graphs/contributors) | Thanks to [all code contributors](https://github.com/xinnan-tech/xiaozhi-esp32-server/graphs/contributors), your efforts have made the project more robust and powerful. |
|
||||||
|
|
||||||
|
|
||||||
<a href="https://star-history.com/#xinnan-tech/xiaozhi-esp32-server&Date">
|
<a href="https://star-history.com/#xinnan-tech/xiaozhi-esp32-server&Date">
|
||||||
|
|||||||
+6
-7
@@ -46,7 +46,7 @@ cat << "EOF"
|
|||||||
\/ \__,_||_| |_||_||_||_| \__,_| |_| \_| \__,_||_| |_||_| \__,_| \__,_|
|
\/ \__,_||_| |_||_||_||_| \__,_| |_| \_| \__,_||_| |_||_| \__,_| \__,_|
|
||||||
EOF
|
EOF
|
||||||
echo -e "\e[0m" # 重置颜色
|
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
|
sleep 1
|
||||||
|
|
||||||
|
|
||||||
@@ -376,7 +376,7 @@ done
|
|||||||
|
|
||||||
echo "服务端启动成功!正在完成配置..."
|
echo "服务端启动成功!正在完成配置..."
|
||||||
echo "正在启动服务..."
|
echo "正在启动服务..."
|
||||||
docker compose -f docker-compose_all.yml up -d
|
docker compose -f /opt/xiaozhi-server/docker-compose_all.yml up -d
|
||||||
echo "服务启动完成!"
|
echo "服务启动完成!"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -402,13 +402,12 @@ fi
|
|||||||
|
|
||||||
# 获取并显示地址信息
|
# 获取并显示地址信息
|
||||||
LOCAL_IP=$(hostname -I | awk '{print $1}')
|
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 "\
|
whiptail --title "安装完成!" --msgbox "\
|
||||||
服务端相关地址如下:\n\
|
服务端相关地址如下:\n\
|
||||||
管理后台访问地址: http://$LOCAL_IP:8002\n\
|
管理后台访问地址: http://$LOCAL_IP:8002\n\
|
||||||
OTA 地址: http://$LOCAL_IP:8002/xiaozhi/ota/\n\
|
OTA 地址: http://$LOCAL_IP:8002/xiaozhi/ota/\n\
|
||||||
视觉分析接口地址: $VISION_ADDR\n\
|
视觉分析接口地址: http://$LOCAL_IP:8003/mcp/vision/explain\n\
|
||||||
WebSocket 地址: $WEBSOCKET_ADDR\n\
|
WebSocket 地址: ws://$LOCAL_IP:8000/xiaozhi/v1/\n\
|
||||||
\n安装完毕!感谢您的使用!\n按Enter键退出..." 16 70
|
\n安装完毕!感谢您的使用!\n按Enter键退出..." 16 70
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ docker镜像已支持x86架构、arm64架构的CPU,支持在国产操作系统
|
|||||||
|
|
||||||
如果您的电脑还没安装docker,可以按照这里的教程安装:[docker安装](https://www.runoob.com/docker/ubuntu-docker-install.html)
|
如果您的电脑还没安装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 安装全模块有两种方式,你可以[使用懒人脚本](./Deployment_all.md#11-懒人脚本)(作者[@VanillaNahida](https://github.com/VanillaNahida))
|
||||||
|
脚本会自动帮你下载所需的文件和配置文件,你也可以使用[手动部署](./Deployment_all.md#12-手动部署)从零搭建。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### 1.1 懒人脚本
|
### 1.1 懒人脚本
|
||||||
|
部署简便,可以参考[视频教程](https://www.bilibili.com/video/BV17bbvzHExd/) ,文字版教程如下:
|
||||||
你可以使用以下命令一键安装全模块版小智服务端:
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> 暂且只支持Ubuntu服务器一键部署,其他系统未尝试,可能会有一些奇怪的bug
|
> 暂且只支持Ubuntu服务器一键部署,其他系统未尝试,可能会有一些奇怪的bug
|
||||||
|
|
||||||
@@ -474,7 +476,8 @@ ws://你电脑局域网的ip:8000/xiaozhi/v1/
|
|||||||
4、[如何部署MCP接入点](./mcp-endpoint-enable.md)<br/>
|
4、[如何部署MCP接入点](./mcp-endpoint-enable.md)<br/>
|
||||||
5、[如何接入MCP接入点](./mcp-endpoint-integration.md)<br/>
|
5、[如何接入MCP接入点](./mcp-endpoint-integration.md)<br/>
|
||||||
6、[如何开启声纹识别](./voiceprint-integration.md)<br/>
|
6、[如何开启声纹识别](./voiceprint-integration.md)<br/>
|
||||||
10、[新闻插件源配置指南](./newsnow_plugin_config.md)<br/>
|
7、[新闻插件源配置指南](./newsnow_plugin_config.md)<br/>
|
||||||
|
8、[天气插件使用指南](./weather-integration.md)<br/>
|
||||||
## 语音克隆、本地语音部署相关教程
|
## 语音克隆、本地语音部署相关教程
|
||||||
1、[如何部署集成index-tts本地语音](./index-stream-integration.md)<br/>
|
1、[如何部署集成index-tts本地语音](./index-stream-integration.md)<br/>
|
||||||
2、[如何部署集成fish-speech本地语音](./fish-speech-integration.md)<br/>
|
2、[如何部署集成fish-speech本地语音](./fish-speech-integration.md)<br/>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 306 KiB After Width: | Height: | Size: 111 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -75,7 +75,7 @@ TTS:
|
|||||||
sample_rate: 24000 # 采样率 [websocket默认24000,http默认0 自动选择]
|
sample_rate: 24000 # 采样率 [websocket默认24000,http默认0 自动选择]
|
||||||
speed: 1.0 # 语速,1.0 表示正常语速,>1 表示加快,<1 表示减慢
|
speed: 1.0 # 语速,1.0 表示正常语速,>1 表示加快,<1 表示减慢
|
||||||
volume: 1.0 # 音量,1.0 表示正常音量,>1 表示增大,<1 表示减小
|
volume: 1.0 # 音量,1.0 表示正常音量,>1 表示增大,<1 表示减小
|
||||||
save_path: ./streaming_tts.wav # 服务器生成的语音文件保存路径
|
save_path: # 保存路径
|
||||||
```
|
```
|
||||||
### 3.启动xiaozhi服务
|
### 3.启动xiaozhi服务
|
||||||
```py
|
```py
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# 语音识别、大语言模型、非流式语音合成、视觉模型的性能测试工具使用指南
|
# 语音识别、大语言模型、非流式语音合成、流式语音合成、视觉模型的性能测试工具使用指南
|
||||||
|
|
||||||
1.在main/xiaozhi-server目录下创建data目录
|
1.在main/xiaozhi-server目录下创建data目录
|
||||||
2.在data目录下创建.config.yaml文件
|
2.在data目录下创建.config.yaml文件
|
||||||
3.在.data/config.yaml中,写入你的语音识别、大语言模型、非流式语音合成、视觉模型的参数
|
3.在.data/config.yaml中,写入你的语音识别、大语言模型、流式语音合成、视觉模型的参数
|
||||||
例如:
|
例如:
|
||||||
```
|
```
|
||||||
LLM:
|
LLM:
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# 天气插件使用指南
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
天气插件 `get_weather` 是小智ESP32语音助手的核心功能之一,支持通过语音查询全国各地的天气信息。插件基于和风天气API,提供实时天气和7天天气预报功能。
|
||||||
|
|
||||||
|
## API Key 申请指南
|
||||||
|
|
||||||
|
### 1. 注册和风天气账号
|
||||||
|
|
||||||
|
1. 访问 [和风天气控制台](https://console.qweather.com/)
|
||||||
|
2. 注册账号并完成邮箱验证
|
||||||
|
3. 登录控制台
|
||||||
|
|
||||||
|
### 2. 创建应用获取API Key
|
||||||
|
|
||||||
|
1. 进入控制台后,点击右侧["项目管理"](https://console.qweather.com/project?lang=zh) → "创建项目"
|
||||||
|
2. 填写项目信息:
|
||||||
|
- **项目名称**:如"小智语音助手"
|
||||||
|
3. 点击保存
|
||||||
|
4. 项目创建完成后,在该项目中点击"创建凭据"
|
||||||
|
5. 填写凭据信息:
|
||||||
|
- **凭据名称**:如"小智语音助手"
|
||||||
|
- **身份认证方式**:选择"API Key"
|
||||||
|
6. 点击保存
|
||||||
|
7. 在凭据中复制`API Key`,这是第一个关键的配置信息
|
||||||
|
|
||||||
|
### 3. 获取API Host
|
||||||
|
|
||||||
|
1. 在控制台中点击["设置"](https://console.qweather.com/setting?lang=zh) → "API Host"
|
||||||
|
2. 查看分配给你的专属`API Host`地址,这个是第二个关键的配置信息
|
||||||
|
|
||||||
|
以上操作,会得到两个重要的配置信息:`API Key`和`API Host`
|
||||||
|
|
||||||
|
## 配置方式(任选一种)
|
||||||
|
|
||||||
|
### 方式1. 如果你使用了智控台部署(推荐)
|
||||||
|
|
||||||
|
1. 登录智控台
|
||||||
|
2. 进入"角色配置"页面
|
||||||
|
3. 选择要配置的智能体
|
||||||
|
4. 点击"编辑功能"按钮
|
||||||
|
5. 在右侧参数配置区域找到"天气查询"插件
|
||||||
|
6. 勾选"天气查询"
|
||||||
|
7. 将复制过来的第一个关键配置`API Key`,填入到`天气插件 API 密钥`里
|
||||||
|
8. 将复制过来的第二个关键配置`API Host`,填入到`开发者 API Host`里
|
||||||
|
9. 保存配置,再保存智能体配置
|
||||||
|
|
||||||
|
### 方式2. 如果你只是单模块xiaozhi-server部署
|
||||||
|
|
||||||
|
在 `data/.config.yaml` 中配置:
|
||||||
|
|
||||||
|
1. 将复制过来的第一个关键配置`API Key`,填入到`api_key`里
|
||||||
|
2. 将复制过来的第二个关键配置`API Host`,填入到`api_host`里
|
||||||
|
3. 将你所在的城市填入到`default_location`里,例如`广州`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
plugins:
|
||||||
|
get_weather:
|
||||||
|
api_key: "你的和风天气API密钥"
|
||||||
|
api_host: "你的和风天气API主机地址"
|
||||||
|
default_location: "你的默认查询城市"
|
||||||
|
```
|
||||||
|
|
||||||
@@ -237,7 +237,7 @@ public interface Constant {
|
|||||||
/**
|
/**
|
||||||
* 版本号
|
* 版本号
|
||||||
*/
|
*/
|
||||||
public static final String VERSION = "0.7.5";
|
public static final String VERSION = "0.7.7";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 无效固件URL
|
* 无效固件URL
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ const agentId = computed(() => pluginStore.currentAgentId)
|
|||||||
const mcpAddress = ref('')
|
const mcpAddress = ref('')
|
||||||
const mcpTools = ref<string[]>([])
|
const mcpTools = ref<string[]>([])
|
||||||
|
|
||||||
|
// 初始化时从本地存储加载MCP地址
|
||||||
|
if (uni.getStorageSync('cachedMcpAddress_' + agentId.value)) {
|
||||||
|
mcpAddress.value = uni.getStorageSync('cachedMcpAddress_' + agentId.value)
|
||||||
|
}
|
||||||
|
|
||||||
// 参数编辑相关
|
// 参数编辑相关
|
||||||
const showParamDialog = ref(false)
|
const showParamDialog = ref(false)
|
||||||
const currentFunction = ref<any>(null)
|
const currentFunction = ref<any>(null)
|
||||||
@@ -56,12 +61,23 @@ async function mergeFunctions() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (agentId.value) {
|
if (agentId.value) {
|
||||||
const [address, tools] = await Promise.all([
|
// 优先获取并显示MCP地址
|
||||||
getMcpAddress(agentId.value),
|
try {
|
||||||
getMcpTools(agentId.value),
|
const address = await getMcpAddress(agentId.value)
|
||||||
])
|
mcpAddress.value = address
|
||||||
mcpAddress.value = address
|
// 缓存到本地存储,下次打开页面可以立即显示
|
||||||
mcpTools.value = tools || []
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const cacheInfo = reactive({
|
|||||||
|
|
||||||
// 服务端地址设置
|
// 服务端地址设置
|
||||||
const baseUrlInput = ref('')
|
const baseUrlInput = ref('')
|
||||||
|
const urlError = ref('')
|
||||||
|
|
||||||
// 系统信息(保留)
|
// 系统信息(保留)
|
||||||
const systemInfo = computed(() => {
|
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() {
|
async function saveServerBaseUrl() {
|
||||||
if (!baseUrlInput.value || !/^https?:\/\//.test(baseUrlInput.value)) {
|
if (!baseUrlInput.value || !/^https?:\/\/.+\/xiaozhi$/.test(baseUrlInput.value)) {
|
||||||
toast.warning('请输入有效的服务端地址(以 http 或 https 开头)')
|
toast.warning('请输入有效的服务端地址(以 http 或 https 开头,并以 /xiaozhi 结尾)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试地址有效性
|
||||||
|
const isServerValid = await testServerBaseUrl()
|
||||||
|
if (!isServerValid) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setServerBaseUrlOverride(baseUrlInput.value)
|
setServerBaseUrlOverride(baseUrlInput.value)
|
||||||
@@ -173,7 +222,7 @@ function showAbout() {
|
|||||||
title: `关于${import.meta.env.VITE_APP_TITLE}`,
|
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`,
|
content: `${import.meta.env.VITE_APP_TITLE}\n\n基于 Vue.js 3 + uni-app 构建的跨平台移动端管理应用,为小智ESP32智能硬件提供设备管理、智能体配置等功能。\n\n© 2025 xiaozhi-esp32-server`,
|
||||||
title: `关于小智智控台`,
|
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.7`,
|
||||||
showCancel: false,
|
showCancel: false,
|
||||||
confirmText: '确定',
|
confirmText: '确定',
|
||||||
})
|
})
|
||||||
@@ -201,7 +250,6 @@ onMounted(async () => {
|
|||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="border border-[#eeeeee] rounded-[24rpx] bg-[#fbfbfb] p-[32rpx]"
|
|
||||||
<view class="border border-[#eeeeee] rounded-[24rpx] bg-[#fbfbfb] p-[32rpx] overflow-hidden"
|
<view class="border border-[#eeeeee] rounded-[24rpx] bg-[#fbfbfb] p-[32rpx] overflow-hidden"
|
||||||
style="box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);">
|
style="box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);">
|
||||||
<view class="mb-[24rpx]">
|
<view class="mb-[24rpx]">
|
||||||
@@ -214,20 +262,15 @@ onMounted(async () => {
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="mb-[24rpx]">
|
<view class="mb-[24rpx]">
|
||||||
<input v-model="baseUrlInput"
|
|
||||||
class="h-[88rpx] w-full border border-[#eeeeee] rounded-[16rpx] bg-[#f5f7fb] px-[24rpx] text-[28rpx] text-[#232338] transition-all focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3] focus:shadow-[0_0_0_4rpx_rgba(51,108,255,0.1)]"
|
|
||||||
type="text" placeholder="输入服务端地址,如 https://example.com/api">
|
|
||||||
<view class="w-full rounded-[16rpx] border border-[#eeeeee] bg-[#f5f7fb] overflow-hidden">
|
<view class="w-full rounded-[16rpx] border border-[#eeeeee] bg-[#f5f7fb] overflow-hidden">
|
||||||
<wd-input
|
<wd-input v-model="baseUrlInput" type="text" clearable :maxlength="200"
|
||||||
v-model="baseUrlInput"
|
placeholder="输入服务端地址,如 https://example.com/xiaozhi"
|
||||||
type="text"
|
|
||||||
clearable
|
|
||||||
:maxlength="200"
|
|
||||||
placeholder="输入服务端地址,如 https://example.com/api"
|
|
||||||
custom-class="!border-none !bg-transparent h-[88rpx] px-[24rpx] items-center"
|
custom-class="!border-none !bg-transparent h-[88rpx] px-[24rpx] items-center"
|
||||||
input-class="text-[28rpx] text-[#232338]"
|
input-class="text-[28rpx] text-[#232338]" @input="validateUrl" @blur="validateUrl" />
|
||||||
/>
|
|
||||||
</view>
|
</view>
|
||||||
|
<text v-if="urlError" class="mt-[8rpx] block text-[24rpx] text-[#ff4d4f]">
|
||||||
|
{{ urlError }}
|
||||||
|
</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="flex gap-[16rpx]">
|
<view class="flex gap-[16rpx]">
|
||||||
@@ -319,6 +362,7 @@ onMounted(async () => {
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 底部安全距离 -->
|
||||||
<!-- 底部安全距离 -->
|
<!-- 底部安全距离 -->
|
||||||
<view style="height: env(safe-area-inset-bottom);" />
|
<view style="height: env(safe-area-inset-bottom);" />
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -698,15 +698,10 @@ export default {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background-color: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
::v-deep .el-input__inner {
|
::v-deep .el-input__inner {
|
||||||
background-color: #f5f5f5;
|
background-color: #f5f5f5 !important;
|
||||||
padding-right: 80px;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.url-input {
|
|
||||||
|
|
||||||
::v-deep .el-input__suffix {
|
::v-deep .el-input__suffix {
|
||||||
right: 0;
|
right: 0;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
class="center-dialog" >
|
class="center-dialog" >
|
||||||
<div style="margin: 0 18px; text-align: left; padding: 10px; border-radius: 10px;">
|
<div style="margin: 0 18px; text-align: left; padding: 10px; border-radius: 10px;">
|
||||||
<div style="font-size: 30px; color: #3d4566; margin-top: -10px; margin-bottom: 10px; text-align: center;">
|
<div style="font-size: 30px; color: #3d4566; margin-top: -10px; margin-bottom: 10px; text-align: center;">
|
||||||
修改模型
|
{{ modelData.duplicateMode ? '创建副本' : '修改模型' }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="custom-close-btn" @click="dialogVisible = false">
|
<button class="custom-close-btn" @click="dialogVisible = false">
|
||||||
@@ -190,6 +190,11 @@ export default {
|
|||||||
Api.model.getModelConfig(this.modelData.id, ({ data }) => {
|
Api.model.getModelConfig(this.modelData.id, ({ data }) => {
|
||||||
if (data.code === 0 && data.data) {
|
if (data.code === 0 && data.data) {
|
||||||
const model = data.data;
|
const model = data.data;
|
||||||
|
|
||||||
|
if (this.modelData.duplicateMode) {
|
||||||
|
model.modelName = this.modelData.modelName + '_副本';
|
||||||
|
model.modelCode = this.modelData.modelCode + '_副本';
|
||||||
|
}
|
||||||
this.pendingProviderType = model.configJson.type;
|
this.pendingProviderType = model.configJson.type;
|
||||||
this.pendingModelData = model;
|
this.pendingModelData = model;
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
<div class="table_bottom">
|
<div class="table_bottom">
|
||||||
<div class="ctrl_btn">
|
<div class="ctrl_btn">
|
||||||
<el-button size="mini" type="primary" class="select-all-btn" @click="handleSelectAll">
|
<el-button size="mini" type="primary" class="select-all-btn" @click="handleSelectAll">
|
||||||
{{ isAllSelected ? '取消全选' : '全选' }}
|
{{ isCurrentPageAllSelected ? '取消全选' : '全选' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button type="success" size="mini" class="add-device-btn" @click="handleAddDevice">
|
<el-button type="success" size="mini" class="add-device-btn" @click="handleAddDevice">
|
||||||
验证码绑定
|
验证码绑定
|
||||||
@@ -128,8 +128,6 @@ export default {
|
|||||||
return {
|
return {
|
||||||
addDeviceDialogVisible: false,
|
addDeviceDialogVisible: false,
|
||||||
manualAddDeviceDialogVisible: false,
|
manualAddDeviceDialogVisible: false,
|
||||||
selectedDevices: [],
|
|
||||||
isAllSelected: false,
|
|
||||||
searchKeyword: "",
|
searchKeyword: "",
|
||||||
activeSearchKeyword: "",
|
activeSearchKeyword: "",
|
||||||
currentAgentId: this.$route.query.agentId || '',
|
currentAgentId: this.$route.query.agentId || '',
|
||||||
@@ -160,6 +158,11 @@ export default {
|
|||||||
pageCount() {
|
pageCount() {
|
||||||
return Math.ceil(this.filteredDeviceList.length / this.pageSize);
|
return Math.ceil(this.filteredDeviceList.length / this.pageSize);
|
||||||
},
|
},
|
||||||
|
// 计算当前页是否全选
|
||||||
|
isCurrentPageAllSelected() {
|
||||||
|
return this.paginatedDeviceList.length > 0 &&
|
||||||
|
this.paginatedDeviceList.every(device => device.selected);
|
||||||
|
},
|
||||||
visiblePages() {
|
visiblePages() {
|
||||||
const pages = [];
|
const pages = [];
|
||||||
const maxVisible = 3;
|
const maxVisible = 3;
|
||||||
@@ -205,16 +208,15 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
handleSelectAll() {
|
handleSelectAll() {
|
||||||
this.isAllSelected = !this.isAllSelected;
|
const shouldSelectAll = !this.isCurrentPageAllSelected;
|
||||||
this.paginatedDeviceList.forEach(row => {
|
this.paginatedDeviceList.forEach(row => {
|
||||||
row.selected = this.isAllSelected;
|
row.selected = shouldSelectAll;
|
||||||
});
|
});
|
||||||
this.selectedDevices = this.paginatedDeviceList.filter(device => device.selected);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteSelected() {
|
deleteSelected() {
|
||||||
this.selectedDevices = this.paginatedDeviceList.filter(device => device.selected);
|
const selectedDevices = this.paginatedDeviceList.filter(device => device.selected);
|
||||||
if (this.selectedDevices.length === 0) {
|
if (selectedDevices.length === 0) {
|
||||||
this.$message.warning({
|
this.$message.warning({
|
||||||
message: '请至少选择一条记录',
|
message: '请至少选择一条记录',
|
||||||
showClose: true
|
showClose: true
|
||||||
@@ -222,12 +224,12 @@ export default {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$confirm(`确认要解绑选中的 ${this.selectedDevices.length} 台设备吗?`, '警告', {
|
this.$confirm(`确认要解绑选中的 ${selectedDevices.length} 台设备吗?`, '警告', {
|
||||||
confirmButtonText: '确定',
|
confirmButtonText: '确定',
|
||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
type: 'warning'
|
type: 'warning'
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
const deviceIds = this.selectedDevices.map(device => device.device_id);
|
const deviceIds = selectedDevices.map(device => device.device_id);
|
||||||
this.batchUnbindDevices(deviceIds);
|
this.batchUnbindDevices(deviceIds);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -250,8 +252,6 @@ export default {
|
|||||||
showClose: true
|
showClose: true
|
||||||
});
|
});
|
||||||
this.fetchBindDevices(this.currentAgentId);
|
this.fetchBindDevices(this.currentAgentId);
|
||||||
this.selectedDevices = [];
|
|
||||||
this.isAllSelected = false;
|
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
this.$message.error({
|
this.$message.error({
|
||||||
@@ -355,7 +355,8 @@ export default {
|
|||||||
isEdit: false,
|
isEdit: false,
|
||||||
_submitting: false,
|
_submitting: false,
|
||||||
otaSwitch: device.autoUpdate === 1,
|
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);
|
.sort((a, b) => a.rawBindTime - b.rawBindTime);
|
||||||
|
|||||||
@@ -79,11 +79,14 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" align="center" width="150px">
|
<el-table-column label="操作" align="center" width="180px">
|
||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
<el-button type="text" size="mini" @click="editModel(scope.row)" class="edit-btn">
|
<el-button type="text" size="mini" @click="editModel(scope.row)" class="edit-btn">
|
||||||
修改
|
修改
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button type="text" size="mini" @click="duplicateModel(scope.row)" class="edit-btn">
|
||||||
|
创建副本
|
||||||
|
</el-button>
|
||||||
<el-button type="text" size="mini" @click="deleteModel(scope.row)" class="delete-btn">
|
<el-button type="text" size="mini" @click="deleteModel(scope.row)" class="delete-btn">
|
||||||
删除
|
删除
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -277,6 +280,11 @@ export default {
|
|||||||
this.editModelData = JSON.parse(JSON.stringify(model));
|
this.editModelData = JSON.parse(JSON.stringify(model));
|
||||||
this.editDialogVisible = true;
|
this.editDialogVisible = true;
|
||||||
},
|
},
|
||||||
|
duplicateModel(model) {
|
||||||
|
this.editModelData = JSON.parse(JSON.stringify(model));
|
||||||
|
this.editModelData.duplicateMode = true;
|
||||||
|
this.editDialogVisible = true;
|
||||||
|
},
|
||||||
// 删除单个模型
|
// 删除单个模型
|
||||||
deleteModel(model) {
|
deleteModel(model) {
|
||||||
this.$confirm('确定要删除该模型吗?', '提示', {
|
this.$confirm('确定要删除该模型吗?', '提示', {
|
||||||
@@ -313,19 +321,34 @@ export default {
|
|||||||
const modelType = this.activeTab;
|
const modelType = this.activeTab;
|
||||||
const id = formData.id;
|
const id = formData.id;
|
||||||
|
|
||||||
Api.model.updateModel(
|
if (this.editModelData.duplicateMode) {
|
||||||
{ modelType, provideCode, id, formData },
|
Api.model.addModel({modelType, provideCode, formData},
|
||||||
({ data }) => {
|
({ data }) => {
|
||||||
if (data.code === 0) {
|
if (data.code === 0) {
|
||||||
this.$message.success('保存成功');
|
this.$message.success('创建副本成功');
|
||||||
this.loadData();
|
this.loadData();
|
||||||
this.editDialogVisible = false;
|
this.editDialogVisible = false;
|
||||||
} else {
|
} else {
|
||||||
this.$message.error(data.msg || '保存失败');
|
this.$message.error(data.msg || '创建副本失败');
|
||||||
}
|
}
|
||||||
done && done(); // 调用done回调关闭加载状态
|
done && done(); // 调用done回调关闭加载状态
|
||||||
}
|
})
|
||||||
);
|
}
|
||||||
|
else {
|
||||||
|
Api.model.updateModel(
|
||||||
|
{ modelType, provideCode, id, formData },
|
||||||
|
({ data }) => {
|
||||||
|
if (data.code === 0) {
|
||||||
|
this.$message.success('保存成功');
|
||||||
|
this.loadData();
|
||||||
|
this.editDialogVisible = false;
|
||||||
|
} else {
|
||||||
|
this.$message.error(data.msg || '保存失败');
|
||||||
|
}
|
||||||
|
done && done(); // 调用done回调关闭加载状态
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
selectAll() {
|
selectAll() {
|
||||||
if (this.isAllSelected) {
|
if (this.isAllSelected) {
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ delete_audio: true
|
|||||||
close_connection_no_voice_time: 120
|
close_connection_no_voice_time: 120
|
||||||
# TTS请求超时时间(秒)
|
# TTS请求超时时间(秒)
|
||||||
tts_timeout: 10
|
tts_timeout: 10
|
||||||
|
# 开启唤醒词加速
|
||||||
|
enable_wakeup_words_response_cache: true
|
||||||
|
# 开场是否回复唤醒词
|
||||||
|
enable_greeting: true
|
||||||
# 说完话是否开启提示音
|
# 说完话是否开启提示音
|
||||||
enable_stop_tts_notify: false
|
enable_stop_tts_notify: false
|
||||||
# 说完话是否开启提示音,音效地址
|
# 说完话是否开启提示音,音效地址
|
||||||
@@ -909,7 +913,7 @@ TTS:
|
|||||||
sample_rate: 24000 # 采样率 [websocket默认24000,http默认0 自动选择]
|
sample_rate: 24000 # 采样率 [websocket默认24000,http默认0 自动选择]
|
||||||
speed: 1.0 # 语速,1.0 表示正常语速,>1 表示加快,<1 表示减慢
|
speed: 1.0 # 语速,1.0 表示正常语速,>1 表示加快,<1 表示减慢
|
||||||
volume: 1.0 # 音量,1.0 表示正常音量,>1 表示增大,<1 表示减小
|
volume: 1.0 # 音量,1.0 表示正常音量,>1 表示增大,<1 表示减小
|
||||||
save_path: ./streaming_tts.wav # 服务器生成的语音文件保存路径
|
save_path: # 保存路径
|
||||||
IndexStreamTTS:
|
IndexStreamTTS:
|
||||||
# 基于Index-TTS-vLLM项目的TTS接口服务
|
# 基于Index-TTS-vLLM项目的TTS接口服务
|
||||||
# 参照教程:https://github.com/Ksuriuri/index-tts-vllm/blob/master/README.md
|
# 参照教程:https://github.com/Ksuriuri/index-tts-vllm/blob/master/README.md
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from config.config_loader import load_config
|
|||||||
from config.settings import check_config_file
|
from config.settings import check_config_file
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
SERVER_VERSION = "0.7.5"
|
SERVER_VERSION = "0.7.7"
|
||||||
_logger_initialized = False
|
_logger_initialized = False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -140,10 +140,6 @@ class ConnectionHandler:
|
|||||||
self.func_handler = None
|
self.func_handler = None
|
||||||
|
|
||||||
self.cmd_exit = self.config["exit_commands"]
|
self.cmd_exit = self.config["exit_commands"]
|
||||||
self.max_cmd_length = 0
|
|
||||||
for cmd in self.cmd_exit:
|
|
||||||
if len(cmd) > self.max_cmd_length:
|
|
||||||
self.max_cmd_length = len(cmd)
|
|
||||||
|
|
||||||
# 是否在聊天结束后关闭连接
|
# 是否在聊天结束后关闭连接
|
||||||
self.close_after_chat = False
|
self.close_after_chat = False
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
|
import time
|
||||||
import json
|
import json
|
||||||
|
import random
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from core.utils.dialogue import Message
|
||||||
|
from core.utils.util import audio_to_data
|
||||||
|
from core.providers.tts.dto.dto import SentenceType
|
||||||
|
from core.utils.wakeup_word import WakeupWordsConfig
|
||||||
|
from core.handle.sendAudioHandle import sendAudioMessage, send_stt_message
|
||||||
|
from core.utils.util import remove_punctuation_and_length, opus_datas_to_wav_bytes
|
||||||
from core.providers.tools.device_mcp import (
|
from core.providers.tools.device_mcp import (
|
||||||
MCPClient,
|
MCPClient,
|
||||||
send_mcp_initialize_message,
|
send_mcp_initialize_message,
|
||||||
@@ -8,6 +16,17 @@ from core.providers.tools.device_mcp import (
|
|||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
|
|
||||||
|
WAKEUP_CONFIG = {
|
||||||
|
"refresh_time": 5,
|
||||||
|
"words": ["你好", "你好啊", "嘿,你好", "嗨"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# 创建全局的唤醒词配置管理器
|
||||||
|
wakeup_words_config = WakeupWordsConfig()
|
||||||
|
|
||||||
|
# 用于防止并发调用wakeupWordsResponse的锁
|
||||||
|
_wakeup_response_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
async def handleHelloMessage(conn, msg_json):
|
async def handleHelloMessage(conn, msg_json):
|
||||||
"""处理hello消息"""
|
"""处理hello消息"""
|
||||||
@@ -30,3 +49,103 @@ async def handleHelloMessage(conn, msg_json):
|
|||||||
asyncio.create_task(send_mcp_tools_list_request(conn))
|
asyncio.create_task(send_mcp_tools_list_request(conn))
|
||||||
|
|
||||||
await conn.websocket.send(json.dumps(conn.welcome_msg))
|
await conn.websocket.send(json.dumps(conn.welcome_msg))
|
||||||
|
|
||||||
|
|
||||||
|
async def checkWakeupWords(conn, text):
|
||||||
|
enable_wakeup_words_response_cache = conn.config[
|
||||||
|
"enable_wakeup_words_response_cache"
|
||||||
|
]
|
||||||
|
|
||||||
|
# 等待tts初始化,最多等待3秒
|
||||||
|
start_time = time.time()
|
||||||
|
while time.time() - start_time < 3:
|
||||||
|
if conn.tts:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not enable_wakeup_words_response_cache:
|
||||||
|
return False
|
||||||
|
|
||||||
|
_, filtered_text = remove_punctuation_and_length(text)
|
||||||
|
if filtered_text not in conn.config.get("wakeup_words"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
conn.just_woken_up = True
|
||||||
|
await send_stt_message(conn, text)
|
||||||
|
|
||||||
|
# 获取当前音色
|
||||||
|
voice = getattr(conn.tts, "voice", "default")
|
||||||
|
if not voice:
|
||||||
|
voice = "default"
|
||||||
|
|
||||||
|
# 获取唤醒词回复配置
|
||||||
|
response = wakeup_words_config.get_wakeup_response(voice)
|
||||||
|
if not response or not response.get("file_path"):
|
||||||
|
response = {
|
||||||
|
"voice": "default",
|
||||||
|
"file_path": "config/assets/wakeup_words.wav",
|
||||||
|
"time": 0,
|
||||||
|
"text": "哈啰啊,我是小智啦,声音好听的台湾女孩一枚,超开心认识你耶,最近在忙啥,别忘了给我来点有趣的料哦,我超爱听八卦的啦",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 获取音频数据
|
||||||
|
opus_packets = audio_to_data(response.get("file_path"))
|
||||||
|
# 播放唤醒词回复
|
||||||
|
conn.client_abort = False
|
||||||
|
|
||||||
|
conn.logger.bind(tag=TAG).info(f"播放唤醒词回复: {response.get('text')}")
|
||||||
|
await sendAudioMessage(conn, SentenceType.FIRST, opus_packets, response.get("text"))
|
||||||
|
await sendAudioMessage(conn, SentenceType.LAST, [], None)
|
||||||
|
|
||||||
|
# 补充对话
|
||||||
|
conn.dialogue.put(Message(role="assistant", content=response.get("text")))
|
||||||
|
|
||||||
|
# 检查是否需要更新唤醒词回复
|
||||||
|
if time.time() - response.get("time", 0) > WAKEUP_CONFIG["refresh_time"]:
|
||||||
|
if not _wakeup_response_lock.locked():
|
||||||
|
asyncio.create_task(wakeupWordsResponse(conn))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def wakeupWordsResponse(conn):
|
||||||
|
if not conn.tts or not conn.llm or not conn.llm.response_no_stream:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 尝试获取锁,如果获取不到就返回
|
||||||
|
if not await _wakeup_response_lock.acquire():
|
||||||
|
return
|
||||||
|
|
||||||
|
# 生成唤醒词回复
|
||||||
|
wakeup_word = random.choice(WAKEUP_CONFIG["words"])
|
||||||
|
question = (
|
||||||
|
"此刻用户正在和你说```"
|
||||||
|
+ wakeup_word
|
||||||
|
+ "```。\n请你根据以上用户的内容进行20-30字回复。要符合系统设置的角色情感和态度,不要像机器人一样说话。\n"
|
||||||
|
+ "请勿对这条内容本身进行任何解释和回应,请勿返回表情符号,仅返回对用户的内容的回复。"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = conn.llm.response_no_stream(conn.config["prompt"], question)
|
||||||
|
if not result or len(result) == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 生成TTS音频
|
||||||
|
tts_result = await asyncio.to_thread(conn.tts.to_tts, result)
|
||||||
|
if not tts_result:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 获取当前音色
|
||||||
|
voice = getattr(conn.tts, "voice", "default")
|
||||||
|
|
||||||
|
wav_bytes = opus_datas_to_wav_bytes(tts_result, sample_rate=16000)
|
||||||
|
file_path = wakeup_words_config.generate_file_path(voice)
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
f.write(wav_bytes)
|
||||||
|
# 更新配置
|
||||||
|
wakeup_words_config.update_wakeup_response(voice, file_path, result)
|
||||||
|
finally:
|
||||||
|
# 确保在任何情况下都释放锁
|
||||||
|
if _wakeup_response_lock.locked():
|
||||||
|
_wakeup_response_lock.release()
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import json
|
import json
|
||||||
import asyncio
|
|
||||||
import uuid
|
import uuid
|
||||||
|
import asyncio
|
||||||
|
from core.utils.dialogue import Message
|
||||||
|
from core.providers.tts.dto.dto import ContentType
|
||||||
|
from core.handle.helloHandle import checkWakeupWords
|
||||||
|
from plugins_func.register import Action, ActionResponse
|
||||||
from core.handle.sendAudioHandle import send_stt_message
|
from core.handle.sendAudioHandle import send_stt_message
|
||||||
from core.utils.util import remove_punctuation_and_length
|
from core.utils.util import remove_punctuation_and_length
|
||||||
from core.providers.tts.dto.dto import ContentType
|
|
||||||
from core.utils.dialogue import Message
|
|
||||||
from plugins_func.register import Action, ActionResponse
|
|
||||||
from core.providers.tts.dto.dto import TTSMessageDTO, SentenceType
|
from core.providers.tts.dto.dto import TTSMessageDTO, SentenceType
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
@@ -23,10 +24,14 @@ async def handle_user_intent(conn, text):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# 检查是否有明确的退出命令
|
# 检查是否有明确的退出命令
|
||||||
filtered_text = remove_punctuation_and_length(text)[1]
|
_, filtered_text = remove_punctuation_and_length(text)
|
||||||
if await check_direct_exit(conn, filtered_text):
|
if await check_direct_exit(conn, filtered_text):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# 检查是否是唤醒词
|
||||||
|
if await checkWakeupWords(conn, filtered_text):
|
||||||
|
return True
|
||||||
|
|
||||||
if conn.intent_type == "function_call":
|
if conn.intent_type == "function_call":
|
||||||
# 使用支持function calling的聊天方法,不再进行意图分析
|
# 使用支持function calling的聊天方法,不再进行意图分析
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import time
|
import time
|
||||||
import asyncio
|
|
||||||
import json
|
import json
|
||||||
from core.handle.sendAudioHandle import send_stt_message
|
import asyncio
|
||||||
|
from core.utils.util import audio_to_data
|
||||||
|
from core.handle.abortHandle import handleAbortMessage
|
||||||
from core.handle.intentHandler import handle_user_intent
|
from core.handle.intentHandler import handle_user_intent
|
||||||
from core.utils.output_counter import check_device_output_limit
|
from core.utils.output_counter import check_device_output_limit
|
||||||
from core.handle.abortHandle import handleAbortMessage
|
from core.handle.sendAudioHandle import send_stt_message, SentenceType
|
||||||
from core.handle.sendAudioHandle import SentenceType
|
|
||||||
from core.utils.util import audio_to_data_stream
|
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
|
|
||||||
@@ -22,7 +21,6 @@ async def handleAudioMessage(conn, audio):
|
|||||||
if not hasattr(conn, "vad_resume_task") or conn.vad_resume_task.done():
|
if not hasattr(conn, "vad_resume_task") or conn.vad_resume_task.done():
|
||||||
conn.vad_resume_task = asyncio.create_task(resume_vad_detection(conn))
|
conn.vad_resume_task = asyncio.create_task(resume_vad_detection(conn))
|
||||||
return
|
return
|
||||||
|
|
||||||
if have_voice:
|
if have_voice:
|
||||||
if conn.client_is_speaking:
|
if conn.client_is_speaking:
|
||||||
await handleAbortMessage(conn)
|
await handleAbortMessage(conn)
|
||||||
@@ -31,13 +29,11 @@ async def handleAudioMessage(conn, audio):
|
|||||||
# 接收音频
|
# 接收音频
|
||||||
await conn.asr.receive_audio(conn, audio, have_voice)
|
await conn.asr.receive_audio(conn, audio, have_voice)
|
||||||
|
|
||||||
|
|
||||||
async def resume_vad_detection(conn):
|
async def resume_vad_detection(conn):
|
||||||
# 等待2秒后恢复VAD检测
|
# 等待2秒后恢复VAD检测
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
conn.just_woken_up = False
|
conn.just_woken_up = False
|
||||||
|
|
||||||
|
|
||||||
async def startToChat(conn, text):
|
async def startToChat(conn, text):
|
||||||
# 检查输入是否是JSON格式(包含说话人信息)
|
# 检查输入是否是JSON格式(包含说话人信息)
|
||||||
speaker_name = None
|
speaker_name = None
|
||||||
@@ -118,12 +114,13 @@ async def no_voice_close_connect(conn, have_voice):
|
|||||||
|
|
||||||
|
|
||||||
async def max_out_size(conn):
|
async def max_out_size(conn):
|
||||||
|
# 播放超出最大输出字数的提示
|
||||||
|
conn.client_abort = False
|
||||||
text = "不好意思,我现在有点事情要忙,明天这个时候我们再聊,约好了哦!明天不见不散,拜拜!"
|
text = "不好意思,我现在有点事情要忙,明天这个时候我们再聊,约好了哦!明天不见不散,拜拜!"
|
||||||
await send_stt_message(conn, text)
|
await send_stt_message(conn, text)
|
||||||
file_path = "config/assets/max_output_size.wav"
|
file_path = "config/assets/max_output_size.wav"
|
||||||
conn.tts.tts_audio_queue.put((SentenceType.FIRST, [], text))
|
opus_packets = audio_to_data(file_path)
|
||||||
play_audio_frames(conn, file_path)
|
conn.tts.tts_audio_queue.put((SentenceType.LAST, opus_packets, text))
|
||||||
conn.tts.tts_audio_queue.put((SentenceType.LAST, [], None))
|
|
||||||
conn.close_after_chat = True
|
conn.close_after_chat = True
|
||||||
|
|
||||||
|
|
||||||
@@ -141,35 +138,25 @@ async def check_bind_device(conn):
|
|||||||
|
|
||||||
# 播放提示音
|
# 播放提示音
|
||||||
music_path = "config/assets/bind_code.wav"
|
music_path = "config/assets/bind_code.wav"
|
||||||
conn.tts.tts_audio_queue.put((SentenceType.FIRST, [], text))
|
opus_packets = audio_to_data(music_path)
|
||||||
play_audio_frames(conn, music_path)
|
conn.tts.tts_audio_queue.put((SentenceType.FIRST, opus_packets, text))
|
||||||
|
|
||||||
# 逐个播放数字
|
# 逐个播放数字
|
||||||
for i in range(6): # 确保只播放6位数字
|
for i in range(6): # 确保只播放6位数字
|
||||||
try:
|
try:
|
||||||
digit = conn.bind_code[i]
|
digit = conn.bind_code[i]
|
||||||
num_path = f"config/assets/bind_code/{digit}.wav"
|
num_path = f"config/assets/bind_code/{digit}.wav"
|
||||||
play_audio_frames(conn, num_path)
|
num_packets = audio_to_data(num_path)
|
||||||
|
conn.tts.tts_audio_queue.put((SentenceType.MIDDLE, num_packets, None))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
conn.logger.bind(tag=TAG).error(f"播放数字音频失败: {e}")
|
conn.logger.bind(tag=TAG).error(f"播放数字音频失败: {e}")
|
||||||
continue
|
continue
|
||||||
conn.tts.tts_audio_queue.put((SentenceType.LAST, [], None))
|
conn.tts.tts_audio_queue.put((SentenceType.LAST, [], None))
|
||||||
else:
|
else:
|
||||||
|
# 播放未绑定提示
|
||||||
|
conn.client_abort = False
|
||||||
text = f"没有找到该设备的版本信息,请正确配置 OTA地址,然后重新编译固件。"
|
text = f"没有找到该设备的版本信息,请正确配置 OTA地址,然后重新编译固件。"
|
||||||
await send_stt_message(conn, text)
|
await send_stt_message(conn, text)
|
||||||
music_path = "config/assets/bind_not_found.wav"
|
music_path = "config/assets/bind_not_found.wav"
|
||||||
conn.tts.tts_audio_queue.put((SentenceType.FIRST, [], text))
|
opus_packets = audio_to_data(music_path)
|
||||||
play_audio_frames(conn, music_path)
|
conn.tts.tts_audio_queue.put((SentenceType.LAST, opus_packets, text))
|
||||||
conn.tts.tts_audio_queue.put((SentenceType.LAST, [], None))
|
|
||||||
|
|
||||||
|
|
||||||
def play_audio_frames(conn, file_path):
|
|
||||||
"""播放音频文件并处理发送帧数据"""
|
|
||||||
def handle_audio_frame(frame_data):
|
|
||||||
conn.tts.tts_audio_queue.put((SentenceType.MIDDLE, frame_data, None))
|
|
||||||
|
|
||||||
audio_to_data_stream(
|
|
||||||
file_path,
|
|
||||||
is_opus=True,
|
|
||||||
callback=handle_audio_frame
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import json
|
import json
|
||||||
from core.providers.tts.dto.dto import SentenceType
|
import time
|
||||||
|
import asyncio
|
||||||
from core.utils import textUtils
|
from core.utils import textUtils
|
||||||
|
from core.utils.util import audio_to_data
|
||||||
|
from core.providers.tts.dto.dto import SentenceType
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
|
|
||||||
@@ -28,13 +31,78 @@ async def sendAudioMessage(conn, sentenceType, audios, text):
|
|||||||
|
|
||||||
|
|
||||||
# 播放音频
|
# 播放音频
|
||||||
async def sendAudio(conn, audios):
|
async def sendAudio(conn, audios, frame_duration=60):
|
||||||
if audios is None:
|
"""
|
||||||
|
发送单个opus包,支持流控
|
||||||
|
Args:
|
||||||
|
conn: 连接对象
|
||||||
|
opus_packet: 单个opus数据包
|
||||||
|
pre_buffer: 快速发送音频
|
||||||
|
frame_duration: 帧时长(毫秒),匹配 Opus 编码
|
||||||
|
"""
|
||||||
|
if audios is None or len(audios) == 0:
|
||||||
return
|
return
|
||||||
# 如果audios不是opus数组,则不需要进行遍历,可以直接发送;这里需要进行流控管理,防止发送过快引发客户端溢出
|
|
||||||
if isinstance(audios, bytes):
|
if isinstance(audios, bytes):
|
||||||
|
if conn.client_abort:
|
||||||
|
return
|
||||||
|
|
||||||
|
conn.last_activity_time = time.time() * 1000
|
||||||
|
|
||||||
|
# 获取或初始化流控状态
|
||||||
|
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)
|
await conn.websocket.send(audios)
|
||||||
|
|
||||||
|
# 更新流控状态
|
||||||
|
flow_control["packet_count"] += 1
|
||||||
|
flow_control["last_send_time"] = time.perf_counter()
|
||||||
|
else:
|
||||||
|
# 文件型音频走普通播放
|
||||||
|
start_time = time.perf_counter()
|
||||||
|
play_position = 0
|
||||||
|
|
||||||
|
# 执行预缓冲
|
||||||
|
pre_buffer_frames = min(3, len(audios))
|
||||||
|
for i in range(pre_buffer_frames):
|
||||||
|
await conn.websocket.send(audios[i])
|
||||||
|
remaining_audios = audios[pre_buffer_frames:]
|
||||||
|
|
||||||
|
# 播放剩余音频帧
|
||||||
|
for opus_packet in remaining_audios:
|
||||||
|
if conn.client_abort:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 重置没有声音的状态
|
||||||
|
conn.last_activity_time = time.time() * 1000
|
||||||
|
|
||||||
|
# 计算预期发送时间
|
||||||
|
expected_time = start_time + (play_position / 1000)
|
||||||
|
current_time = time.perf_counter()
|
||||||
|
delay = expected_time - current_time
|
||||||
|
if delay > 0:
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
await conn.websocket.send(opus_packet)
|
||||||
|
|
||||||
|
play_position += frame_duration
|
||||||
|
|
||||||
|
|
||||||
async def send_tts_message(conn, state, text=None):
|
async def send_tts_message(conn, state, text=None):
|
||||||
"""发送 TTS 状态消息"""
|
"""发送 TTS 状态消息"""
|
||||||
@@ -52,7 +120,7 @@ async def send_tts_message(conn, state, text=None):
|
|||||||
stop_tts_notify_voice = conn.config.get(
|
stop_tts_notify_voice = conn.config.get(
|
||||||
"stop_tts_notify_voice", "config/assets/tts_notify.mp3"
|
"stop_tts_notify_voice", "config/assets/tts_notify.mp3"
|
||||||
)
|
)
|
||||||
audios, _ = conn.tts.audio_to_opus_data(stop_tts_notify_voice)
|
audios = audio_to_data(stop_tts_notify_voice, is_opus=True)
|
||||||
await sendAudio(conn, audios)
|
await sendAudio(conn, audios)
|
||||||
# 清除服务端讲话状态
|
# 清除服务端讲话状态
|
||||||
conn.clearSpeakStatus()
|
conn.clearSpeakStatus()
|
||||||
@@ -72,7 +140,7 @@ async def send_stt_message(conn, text):
|
|||||||
display_text = text
|
display_text = text
|
||||||
try:
|
try:
|
||||||
# 尝试解析JSON格式
|
# 尝试解析JSON格式
|
||||||
if text.strip().startswith('{') and text.strip().endswith('}'):
|
if text.strip().startswith("{") and text.strip().endswith("}"):
|
||||||
parsed_data = json.loads(text)
|
parsed_data = json.loads(text)
|
||||||
if isinstance(parsed_data, dict) and "content" in parsed_data:
|
if isinstance(parsed_data, dict) and "content" in parsed_data:
|
||||||
# 如果是包含说话人信息的JSON格式,只显示content部分
|
# 如果是包含说话人信息的JSON格式,只显示content部分
|
||||||
|
|||||||
@@ -1,154 +1,14 @@
|
|||||||
import json
|
from core.handle.textMessageHandlerRegistry import TextMessageHandlerRegistry
|
||||||
import time
|
from core.handle.textMessageProcessor import TextMessageProcessor
|
||||||
from core.handle.abortHandle import handleAbortMessage
|
|
||||||
from core.handle.helloHandle import handleHelloMessage
|
|
||||||
from core.providers.tools.device_mcp import handle_mcp_message
|
|
||||||
from core.utils.util import remove_punctuation_and_length, filter_sensitive_info
|
|
||||||
from core.handle.receiveAudioHandle import startToChat, handleAudioMessage
|
|
||||||
from core.providers.tools.device_iot import handleIotDescriptors, handleIotStatus
|
|
||||||
from core.handle.reportHandle import enqueue_asr_report
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
|
|
||||||
|
# 全局处理器注册表
|
||||||
|
message_registry = TextMessageHandlerRegistry()
|
||||||
|
|
||||||
|
# 创建全局消息处理器实例
|
||||||
|
message_processor = TextMessageProcessor(message_registry)
|
||||||
|
|
||||||
async def handleTextMessage(conn, message):
|
async def handleTextMessage(conn, message):
|
||||||
"""处理文本消息"""
|
"""处理文本消息"""
|
||||||
try:
|
await message_processor.process_message(conn, message)
|
||||||
msg_json = json.loads(message)
|
|
||||||
if isinstance(msg_json, int):
|
|
||||||
conn.logger.bind(tag=TAG).info(f"收到文本消息:{message}")
|
|
||||||
await conn.websocket.send(message)
|
|
||||||
return
|
|
||||||
if msg_json["type"] == "hello":
|
|
||||||
conn.logger.bind(tag=TAG).info(f"收到hello消息:{message}")
|
|
||||||
await handleHelloMessage(conn, msg_json)
|
|
||||||
elif msg_json["type"] == "abort":
|
|
||||||
conn.logger.bind(tag=TAG).info(f"收到abort消息:{message}")
|
|
||||||
await handleAbortMessage(conn)
|
|
||||||
elif msg_json["type"] == "listen":
|
|
||||||
conn.logger.bind(tag=TAG).info(f"收到listen消息:{message}")
|
|
||||||
if "mode" in msg_json:
|
|
||||||
conn.client_listen_mode = msg_json["mode"]
|
|
||||||
conn.logger.bind(tag=TAG).debug(
|
|
||||||
f"客户端拾音模式:{conn.client_listen_mode}"
|
|
||||||
)
|
|
||||||
if msg_json["state"] == "start":
|
|
||||||
conn.client_have_voice = True
|
|
||||||
conn.client_voice_stop = False
|
|
||||||
elif msg_json["state"] == "stop":
|
|
||||||
conn.client_have_voice = True
|
|
||||||
conn.client_voice_stop = True
|
|
||||||
if len(conn.asr_audio) > 0:
|
|
||||||
await handleAudioMessage(conn, b"")
|
|
||||||
elif msg_json["state"] == "detect":
|
|
||||||
conn.client_have_voice = False
|
|
||||||
conn.asr_audio.clear()
|
|
||||||
if "text" in msg_json:
|
|
||||||
conn.last_activity_time = time.time() * 1000
|
|
||||||
original_text = msg_json["text"] # 保留原始文本
|
|
||||||
filtered_len, filtered_text = remove_punctuation_and_length(
|
|
||||||
original_text
|
|
||||||
)
|
|
||||||
# 识别是否是唤醒词
|
|
||||||
is_wakeup_words = filtered_text in conn.config.get("wakeup_words")
|
|
||||||
if not is_wakeup_words:
|
|
||||||
# 上报纯文字数据(复用ASR上报功能,但不提供音频数据)
|
|
||||||
enqueue_asr_report(conn, original_text, [])
|
|
||||||
# 否则需要LLM对文字内容进行答复
|
|
||||||
await startToChat(conn, original_text)
|
|
||||||
elif msg_json["type"] == "iot":
|
|
||||||
conn.logger.bind(tag=TAG).info(f"收到iot消息:{message}")
|
|
||||||
if "descriptors" in msg_json:
|
|
||||||
asyncio.create_task(handleIotDescriptors(conn, msg_json["descriptors"]))
|
|
||||||
if "states" in msg_json:
|
|
||||||
asyncio.create_task(handleIotStatus(conn, msg_json["states"]))
|
|
||||||
elif msg_json["type"] == "mcp":
|
|
||||||
conn.logger.bind(tag=TAG).info(f"收到mcp消息:{message[:100]}")
|
|
||||||
if "payload" in msg_json:
|
|
||||||
asyncio.create_task(
|
|
||||||
handle_mcp_message(conn, conn.mcp_client, msg_json["payload"])
|
|
||||||
)
|
|
||||||
elif msg_json["type"] == "server":
|
|
||||||
# 记录日志时过滤敏感信息
|
|
||||||
conn.logger.bind(tag=TAG).info(
|
|
||||||
f"收到服务器消息:{filter_sensitive_info(msg_json)}"
|
|
||||||
)
|
|
||||||
# 如果配置是从API读取的,则需要验证secret
|
|
||||||
if not conn.read_config_from_api:
|
|
||||||
return
|
|
||||||
# 获取post请求的secret
|
|
||||||
post_secret = msg_json.get("content", {}).get("secret", "")
|
|
||||||
secret = conn.config["manager-api"].get("secret", "")
|
|
||||||
# 如果secret不匹配,则返回
|
|
||||||
if post_secret != secret:
|
|
||||||
await conn.websocket.send(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"type": "server",
|
|
||||||
"status": "error",
|
|
||||||
"message": "服务器密钥验证失败",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
# 动态更新配置
|
|
||||||
if msg_json["action"] == "update_config":
|
|
||||||
try:
|
|
||||||
# 更新WebSocketServer的配置
|
|
||||||
if not conn.server:
|
|
||||||
await conn.websocket.send(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"type": "server",
|
|
||||||
"status": "error",
|
|
||||||
"message": "无法获取服务器实例",
|
|
||||||
"content": {"action": "update_config"},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if not await conn.server.update_config():
|
|
||||||
await conn.websocket.send(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"type": "server",
|
|
||||||
"status": "error",
|
|
||||||
"message": "更新服务器配置失败",
|
|
||||||
"content": {"action": "update_config"},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# 发送成功响应
|
|
||||||
await conn.websocket.send(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"type": "server",
|
|
||||||
"status": "success",
|
|
||||||
"message": "配置更新成功",
|
|
||||||
"content": {"action": "update_config"},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
conn.logger.bind(tag=TAG).error(f"更新配置失败: {str(e)}")
|
|
||||||
await conn.websocket.send(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"type": "server",
|
|
||||||
"status": "error",
|
|
||||||
"message": f"更新配置失败: {str(e)}",
|
|
||||||
"content": {"action": "update_config"},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# 重启服务器
|
|
||||||
elif msg_json["action"] == "restart":
|
|
||||||
await conn.handle_restart(msg_json)
|
|
||||||
else:
|
|
||||||
conn.logger.bind(tag=TAG).error(f"收到未知类型消息:{message}")
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
await conn.websocket.send(message)
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from core.handle.abortHandle import handleAbortMessage
|
||||||
|
from core.handle.textMessageHandler import TextMessageHandler
|
||||||
|
from core.handle.textMessageType import TextMessageType
|
||||||
|
|
||||||
|
|
||||||
|
class AbortTextMessageHandler(TextMessageHandler):
|
||||||
|
"""Abort消息处理器"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def message_type(self) -> TextMessageType:
|
||||||
|
return TextMessageType.ABORT
|
||||||
|
|
||||||
|
async def handle(self, conn, msg_json: Dict[str, Any]) -> None:
|
||||||
|
await handleAbortMessage(conn)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from core.handle.helloHandle import handleHelloMessage
|
||||||
|
from core.handle.textMessageHandler import TextMessageHandler
|
||||||
|
from core.handle.textMessageType import TextMessageType
|
||||||
|
|
||||||
|
|
||||||
|
class HelloTextMessageHandler(TextMessageHandler):
|
||||||
|
"""Hello消息处理器"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def message_type(self) -> TextMessageType:
|
||||||
|
return TextMessageType.HELLO
|
||||||
|
|
||||||
|
async def handle(self, conn, msg_json: Dict[str, Any]) -> None:
|
||||||
|
await handleHelloMessage(conn, msg_json)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import asyncio
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from core.handle.textMessageHandler import TextMessageHandler
|
||||||
|
from core.handle.textMessageType import TextMessageType
|
||||||
|
from core.providers.tools.device_iot import handleIotStatus, handleIotDescriptors
|
||||||
|
|
||||||
|
|
||||||
|
class IotTextMessageHandler(TextMessageHandler):
|
||||||
|
"""IOT消息处理器"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def message_type(self) -> TextMessageType:
|
||||||
|
return TextMessageType.IOT
|
||||||
|
|
||||||
|
async def handle(self, conn, msg_json: Dict[str, Any]) -> None:
|
||||||
|
if "descriptors" in msg_json:
|
||||||
|
asyncio.create_task(handleIotDescriptors(conn, msg_json["descriptors"]))
|
||||||
|
if "states" in msg_json:
|
||||||
|
asyncio.create_task(handleIotStatus(conn, msg_json["states"]))
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import time
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from core.handle.receiveAudioHandle import handleAudioMessage, startToChat
|
||||||
|
from core.handle.reportHandle import enqueue_asr_report
|
||||||
|
from core.handle.sendAudioHandle import send_stt_message, send_tts_message
|
||||||
|
from core.handle.textMessageHandler import TextMessageHandler
|
||||||
|
from core.handle.textMessageType import TextMessageType
|
||||||
|
from core.utils.util import remove_punctuation_and_length
|
||||||
|
|
||||||
|
TAG = __name__
|
||||||
|
|
||||||
|
class ListenTextMessageHandler(TextMessageHandler):
|
||||||
|
"""Listen消息处理器"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def message_type(self) -> TextMessageType:
|
||||||
|
return TextMessageType.LISTEN
|
||||||
|
|
||||||
|
async def handle(self, conn, msg_json: Dict[str, Any]) -> None:
|
||||||
|
if "mode" in msg_json:
|
||||||
|
conn.client_listen_mode = msg_json["mode"]
|
||||||
|
conn.logger.bind(tag=TAG).debug(
|
||||||
|
f"客户端拾音模式:{conn.client_listen_mode}"
|
||||||
|
)
|
||||||
|
if msg_json["state"] == "start":
|
||||||
|
conn.client_have_voice = True
|
||||||
|
conn.client_voice_stop = False
|
||||||
|
elif msg_json["state"] == "stop":
|
||||||
|
conn.client_have_voice = True
|
||||||
|
conn.client_voice_stop = True
|
||||||
|
if len(conn.asr_audio) > 0:
|
||||||
|
await handleAudioMessage(conn, b"")
|
||||||
|
elif msg_json["state"] == "detect":
|
||||||
|
conn.client_have_voice = False
|
||||||
|
conn.asr_audio.clear()
|
||||||
|
if "text" in msg_json:
|
||||||
|
conn.last_activity_time = time.time() * 1000
|
||||||
|
original_text = msg_json["text"] # 保留原始文本
|
||||||
|
filtered_len, filtered_text = remove_punctuation_and_length(
|
||||||
|
original_text
|
||||||
|
)
|
||||||
|
|
||||||
|
# 识别是否是唤醒词
|
||||||
|
is_wakeup_words = filtered_text in conn.config.get("wakeup_words")
|
||||||
|
# 是否开启唤醒词回复
|
||||||
|
enable_greeting = conn.config.get("enable_greeting", True)
|
||||||
|
|
||||||
|
if is_wakeup_words and not enable_greeting:
|
||||||
|
# 如果是唤醒词,且关闭了唤醒词回复,就不用回答
|
||||||
|
await send_stt_message(conn, original_text)
|
||||||
|
await send_tts_message(conn, "stop", None)
|
||||||
|
conn.client_is_speaking = False
|
||||||
|
elif is_wakeup_words:
|
||||||
|
conn.just_woken_up = True
|
||||||
|
# 上报纯文字数据(复用ASR上报功能,但不提供音频数据)
|
||||||
|
enqueue_asr_report(conn, "嘿,你好呀", [])
|
||||||
|
await startToChat(conn, "嘿,你好呀")
|
||||||
|
else:
|
||||||
|
# 上报纯文字数据(复用ASR上报功能,但不提供音频数据)
|
||||||
|
enqueue_asr_report(conn, original_text, [])
|
||||||
|
# 否则需要LLM对文字内容进行答复
|
||||||
|
await startToChat(conn, original_text)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import asyncio
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from core.handle.textMessageHandler import TextMessageHandler
|
||||||
|
from core.handle.textMessageType import TextMessageType
|
||||||
|
from core.providers.tools.device_mcp import handle_mcp_message
|
||||||
|
|
||||||
|
|
||||||
|
class McpTextMessageHandler(TextMessageHandler):
|
||||||
|
"""MCP消息处理器"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def message_type(self) -> TextMessageType:
|
||||||
|
return TextMessageType.MCP
|
||||||
|
|
||||||
|
async def handle(self, conn, msg_json: Dict[str, Any]) -> None:
|
||||||
|
if "payload" in msg_json:
|
||||||
|
asyncio.create_task(
|
||||||
|
handle_mcp_message(conn, conn.mcp_client, msg_json["payload"])
|
||||||
|
)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from core.handle.textMessageHandler import TextMessageHandler
|
||||||
|
from core.handle.textMessageType import TextMessageType
|
||||||
|
from core.providers.tools.device_mcp import handle_mcp_message
|
||||||
|
|
||||||
|
TAG = __name__
|
||||||
|
|
||||||
|
class ServerTextMessageHandler(TextMessageHandler):
|
||||||
|
"""MCP消息处理器"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def message_type(self) -> TextMessageType:
|
||||||
|
return TextMessageType.SERVER
|
||||||
|
|
||||||
|
async def handle(self, conn, msg_json: Dict[str, Any]) -> None:
|
||||||
|
# 如果配置是从API读取的,则需要验证secret
|
||||||
|
if not conn.read_config_from_api:
|
||||||
|
return
|
||||||
|
# 获取post请求的secret
|
||||||
|
post_secret = msg_json.get("content", {}).get("secret", "")
|
||||||
|
secret = conn.config["manager-api"].get("secret", "")
|
||||||
|
# 如果secret不匹配,则返回
|
||||||
|
if post_secret != secret:
|
||||||
|
await conn.websocket.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "server",
|
||||||
|
"status": "error",
|
||||||
|
"message": "服务器密钥验证失败",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
# 动态更新配置
|
||||||
|
if msg_json["action"] == "update_config":
|
||||||
|
try:
|
||||||
|
# 更新WebSocketServer的配置
|
||||||
|
if not conn.server:
|
||||||
|
await conn.websocket.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "server",
|
||||||
|
"status": "error",
|
||||||
|
"message": "无法获取服务器实例",
|
||||||
|
"content": {"action": "update_config"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not await conn.server.update_config():
|
||||||
|
await conn.websocket.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "server",
|
||||||
|
"status": "error",
|
||||||
|
"message": "更新服务器配置失败",
|
||||||
|
"content": {"action": "update_config"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 发送成功响应
|
||||||
|
await conn.websocket.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "server",
|
||||||
|
"status": "success",
|
||||||
|
"message": "配置更新成功",
|
||||||
|
"content": {"action": "update_config"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
conn.logger.bind(tag=TAG).error(f"更新配置失败: {str(e)}")
|
||||||
|
await conn.websocket.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "server",
|
||||||
|
"status": "error",
|
||||||
|
"message": f"更新配置失败: {str(e)}",
|
||||||
|
"content": {"action": "update_config"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 重启服务器
|
||||||
|
elif msg_json["action"] == "restart":
|
||||||
|
await conn.handle_restart(msg_json)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from abc import abstractmethod, ABC
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from core.handle.textMessageType import TextMessageType
|
||||||
|
|
||||||
|
TAG = __name__
|
||||||
|
|
||||||
|
|
||||||
|
class TextMessageHandler(ABC):
|
||||||
|
"""消息处理器抽象基类"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def handle(self, conn, msg_json: Dict[str, Any]) -> None:
|
||||||
|
"""处理消息的抽象方法"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def message_type(self) -> TextMessageType:
|
||||||
|
"""返回处理的消息类型"""
|
||||||
|
pass
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
from core.handle.textHandler.abortMessageHandler import AbortTextMessageHandler
|
||||||
|
from core.handle.textHandler.helloMessageHandler import HelloTextMessageHandler
|
||||||
|
from core.handle.textHandler.iotMessageHandler import IotTextMessageHandler
|
||||||
|
from core.handle.textHandler.listenMessageHandler import ListenTextMessageHandler
|
||||||
|
from core.handle.textHandler.mcpMessageHandler import McpTextMessageHandler
|
||||||
|
from core.handle.textMessageHandler import TextMessageHandler
|
||||||
|
from core.handle.textHandler.serverMessageHandler import ServerTextMessageHandler
|
||||||
|
|
||||||
|
TAG = __name__
|
||||||
|
|
||||||
|
|
||||||
|
class TextMessageHandlerRegistry:
|
||||||
|
"""消息处理器注册表"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._handlers: Dict[str, TextMessageHandler] = {}
|
||||||
|
self._register_default_handlers()
|
||||||
|
|
||||||
|
def _register_default_handlers(self) -> None:
|
||||||
|
"""注册默认的消息处理器"""
|
||||||
|
handlers = [
|
||||||
|
HelloTextMessageHandler(),
|
||||||
|
AbortTextMessageHandler(),
|
||||||
|
ListenTextMessageHandler(),
|
||||||
|
IotTextMessageHandler(),
|
||||||
|
McpTextMessageHandler(),
|
||||||
|
ServerTextMessageHandler(),
|
||||||
|
]
|
||||||
|
|
||||||
|
for handler in handlers:
|
||||||
|
self.register_handler(handler)
|
||||||
|
|
||||||
|
def register_handler(self, handler: TextMessageHandler) -> None:
|
||||||
|
"""注册消息处理器"""
|
||||||
|
self._handlers[handler.message_type.value] = handler
|
||||||
|
|
||||||
|
def get_handler(self, message_type: str) -> Optional[TextMessageHandler]:
|
||||||
|
"""获取消息处理器"""
|
||||||
|
return self._handlers.get(message_type)
|
||||||
|
|
||||||
|
def get_supported_types(self) -> list:
|
||||||
|
"""获取支持的消息类型"""
|
||||||
|
return list(self._handlers.keys())
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from core.handle.textMessageHandlerRegistry import TextMessageHandlerRegistry
|
||||||
|
|
||||||
|
TAG = __name__
|
||||||
|
|
||||||
|
|
||||||
|
class TextMessageProcessor:
|
||||||
|
"""消息处理器主类"""
|
||||||
|
|
||||||
|
def __init__(self, registry: TextMessageHandlerRegistry):
|
||||||
|
self.registry = registry
|
||||||
|
|
||||||
|
async def process_message(self, conn, message: str) -> None:
|
||||||
|
"""处理消息的主入口"""
|
||||||
|
try:
|
||||||
|
# 解析JSON消息
|
||||||
|
msg_json = json.loads(message)
|
||||||
|
|
||||||
|
# 处理JSON消息
|
||||||
|
if isinstance(msg_json, dict):
|
||||||
|
message_type = msg_json.get("type")
|
||||||
|
|
||||||
|
# 记录日志
|
||||||
|
conn.logger.bind(tag=TAG).info(f"收到{message_type}消息:{message}")
|
||||||
|
|
||||||
|
# 获取并执行处理器
|
||||||
|
handler = self.registry.get_handler(message_type)
|
||||||
|
if handler:
|
||||||
|
await handler.handle(conn, msg_json)
|
||||||
|
else:
|
||||||
|
conn.logger.bind(tag=TAG).error(f"收到未知类型消息:{message}")
|
||||||
|
# 处理纯数字消息
|
||||||
|
elif isinstance(msg_json, int):
|
||||||
|
conn.logger.bind(tag=TAG).info(f"收到数字消息:{message}")
|
||||||
|
await conn.websocket.send(message)
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# 非JSON消息直接转发
|
||||||
|
conn.logger.bind(tag=TAG).error(f"解析到错误的消息:{message}")
|
||||||
|
await conn.websocket.send(message)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class TextMessageType(Enum):
|
||||||
|
"""消息类型枚举"""
|
||||||
|
HELLO = "hello"
|
||||||
|
ABORT = "abort"
|
||||||
|
LISTEN = "listen"
|
||||||
|
IOT = "iot"
|
||||||
|
MCP = "mcp"
|
||||||
|
SERVER = "server"
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
import os
|
import os
|
||||||
|
import io
|
||||||
import wave
|
import wave
|
||||||
import uuid
|
import uuid
|
||||||
|
import json
|
||||||
|
import time
|
||||||
import queue
|
import queue
|
||||||
import asyncio
|
import asyncio
|
||||||
import traceback
|
import traceback
|
||||||
import threading
|
import threading
|
||||||
import opuslib_next
|
import opuslib_next
|
||||||
import json
|
|
||||||
import io
|
|
||||||
import time
|
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from config.logger import setup_logging
|
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.receiveAudioHandle import startToChat
|
||||||
from core.handle.reportHandle import enqueue_asr_report
|
from core.handle.reportHandle import enqueue_asr_report
|
||||||
from core.utils.util import remove_punctuation_and_length
|
from core.utils.util import remove_punctuation_and_length
|
||||||
@@ -87,11 +87,9 @@ class ASRProviderBase(ABC):
|
|||||||
|
|
||||||
# 预先准备WAV数据
|
# 预先准备WAV数据
|
||||||
wav_data = None
|
wav_data = None
|
||||||
# 使用连接的声纹识别提供者
|
|
||||||
if conn.voiceprint_provider and combined_pcm_data:
|
if conn.voiceprint_provider and combined_pcm_data:
|
||||||
wav_data = self._pcm_to_wav(combined_pcm_data)
|
wav_data = self._pcm_to_wav(combined_pcm_data)
|
||||||
|
|
||||||
|
|
||||||
# 定义ASR任务
|
# 定义ASR任务
|
||||||
def run_asr():
|
def run_asr():
|
||||||
start_time = time.monotonic()
|
start_time = time.monotonic()
|
||||||
@@ -132,8 +130,6 @@ class ASRProviderBase(ABC):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# 使用线程池执行器并行运行
|
# 使用线程池执行器并行运行
|
||||||
parallel_start_time = time.monotonic()
|
|
||||||
|
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as thread_executor:
|
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as thread_executor:
|
||||||
asr_future = thread_executor.submit(run_asr)
|
asr_future = thread_executor.submit(run_asr)
|
||||||
|
|
||||||
@@ -151,7 +147,7 @@ class ASRProviderBase(ABC):
|
|||||||
|
|
||||||
|
|
||||||
# 处理结果
|
# 处理结果
|
||||||
raw_text, file_path = results.get("asr", ("", None))
|
raw_text, _ = results.get("asr", ("", None))
|
||||||
speaker_name = results.get("voiceprint", None)
|
speaker_name = results.get("voiceprint", None)
|
||||||
|
|
||||||
# 记录识别结果
|
# 记录识别结果
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from config.logger import setup_logging
|
from config.logger import setup_logging
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
import dashscope
|
||||||
from dashscope import Application
|
from dashscope import Application
|
||||||
from core.providers.llm.base import LLMProviderBase
|
from core.providers.llm.base import LLMProviderBase
|
||||||
from core.utils.util import check_model_key
|
from core.utils.util import check_model_key
|
||||||
|
import time
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
logger = setup_logging()
|
logger = setup_logging()
|
||||||
@@ -15,6 +17,7 @@ class LLMProvider(LLMProviderBase):
|
|||||||
self.base_url = config.get("base_url")
|
self.base_url = config.get("base_url")
|
||||||
self.is_No_prompt = config.get("is_no_prompt")
|
self.is_No_prompt = config.get("is_no_prompt")
|
||||||
self.memory_id = config.get("ali_memory_id")
|
self.memory_id = config.get("ali_memory_id")
|
||||||
|
self.streaming_chunk_size = config.get("streaming_chunk_size", 3) # 每次流式返回的字符数
|
||||||
check_model_key("AliBLLLM", self.api_key)
|
check_model_key("AliBLLLM", self.api_key)
|
||||||
|
|
||||||
def response(self, session_id, dialogue):
|
def response(self, session_id, dialogue):
|
||||||
@@ -32,6 +35,8 @@ class LLMProvider(LLMProviderBase):
|
|||||||
"app_id": self.app_id,
|
"app_id": self.app_id,
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
"messages": dialogue,
|
"messages": dialogue,
|
||||||
|
# 开启SDK原生流式
|
||||||
|
"stream": True,
|
||||||
}
|
}
|
||||||
if self.memory_id != False:
|
if self.memory_id != False:
|
||||||
# 百练memory需要prompt参数
|
# 百练memory需要prompt参数
|
||||||
@@ -42,25 +47,63 @@ class LLMProvider(LLMProviderBase):
|
|||||||
f"【阿里百练API服务】处理后的prompt: {prompt}"
|
f"【阿里百练API服务】处理后的prompt: {prompt}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 可选地设置自定义API基地址(若配置为兼容模式URL则忽略)
|
||||||
|
if self.base_url and ("/api/" in self.base_url):
|
||||||
|
dashscope.base_http_api_url = self.base_url
|
||||||
|
|
||||||
responses = Application.call(**call_params)
|
responses = Application.call(**call_params)
|
||||||
if responses.status_code != HTTPStatus.OK:
|
|
||||||
logger.bind(tag=TAG).error(
|
# 流式处理(SDK在stream=True时返回可迭代对象;否则返回单次响应对象)
|
||||||
f"code={responses.status_code}, "
|
logger.bind(tag=TAG).debug(
|
||||||
f"message={responses.message}, "
|
f"【阿里百练API服务】构造参数: {dict(call_params, api_key='***')}"
|
||||||
f"请参考文档:https://help.aliyun.com/zh/model-studio/developer-reference/error-code"
|
)
|
||||||
)
|
|
||||||
yield "【阿里百练API服务响应异常】"
|
last_text = ""
|
||||||
else:
|
try:
|
||||||
logger.bind(tag=TAG).debug(
|
for resp in responses:
|
||||||
f"【阿里百练API服务】构造参数: {call_params}"
|
if resp.status_code != HTTPStatus.OK:
|
||||||
)
|
logger.bind(tag=TAG).error(
|
||||||
yield responses.output.text
|
f"code={resp.status_code}, message={resp.message}, 请参考文档:https://help.aliyun.com/zh/model-studio/developer-reference/error-code"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
current_text = getattr(getattr(resp, "output", None), "text", None)
|
||||||
|
if current_text is None:
|
||||||
|
continue
|
||||||
|
# SDK流式为增量覆盖,计算差量输出
|
||||||
|
if len(current_text) >= len(last_text):
|
||||||
|
delta = current_text[len(last_text):]
|
||||||
|
else:
|
||||||
|
# 避免偶发回退
|
||||||
|
delta = current_text
|
||||||
|
if delta:
|
||||||
|
yield delta
|
||||||
|
last_text = current_text
|
||||||
|
except TypeError:
|
||||||
|
# 非流式回落(一次性返回)
|
||||||
|
if responses.status_code != HTTPStatus.OK:
|
||||||
|
logger.bind(tag=TAG).error(
|
||||||
|
f"code={responses.status_code}, message={responses.message}, 请参考文档:https://help.aliyun.com/zh/model-studio/developer-reference/error-code"
|
||||||
|
)
|
||||||
|
yield "【阿里百练API服务响应异常】"
|
||||||
|
else:
|
||||||
|
full_text = getattr(getattr(responses, "output", None), "text", "")
|
||||||
|
logger.bind(tag=TAG).info(
|
||||||
|
f"【阿里百练API服务】完整响应长度: {len(full_text)}"
|
||||||
|
)
|
||||||
|
for i in range(0, len(full_text), self.streaming_chunk_size):
|
||||||
|
chunk = full_text[i:i + self.streaming_chunk_size]
|
||||||
|
if chunk:
|
||||||
|
yield chunk
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.bind(tag=TAG).error(f"【阿里百练API服务】响应异常: {e}")
|
logger.bind(tag=TAG).error(f"【阿里百练API服务】响应异常: {e}")
|
||||||
yield "【LLM服务响应异常】"
|
yield "【LLM服务响应异常】"
|
||||||
|
|
||||||
def response_with_functions(self, session_id, dialogue, functions=None):
|
def response_with_functions(self, session_id, dialogue, functions=None):
|
||||||
logger.bind(tag=TAG).error(
|
# 阿里百练当前未支持原生的 function call。为保持兼容,这里回退到普通文本流式输出。
|
||||||
f"阿里百练暂未实现完整的工具调用(function call),建议使用其他意图识别"
|
# 上层会按 (content, tool_calls) 的形式消费,这里始终返回 (token, None)
|
||||||
|
logger.bind(tag=TAG).warning(
|
||||||
|
"阿里百练未实现原生 function call,已回退为纯文本流式输出"
|
||||||
)
|
)
|
||||||
|
for token in self.response(session_id, dialogue):
|
||||||
|
yield token, None
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ class ServerMCPExecutor(ToolExecutor):
|
|||||||
"""初始化MCP管理器"""
|
"""初始化MCP管理器"""
|
||||||
if not self._initialized:
|
if not self._initialized:
|
||||||
self.mcp_manager = ServerMCPManager(self.conn)
|
self.mcp_manager = ServerMCPManager(self.conn)
|
||||||
await self.mcp_manager.initialize_servers()
|
|
||||||
self._initialized = True
|
self._initialized = True
|
||||||
|
await self.mcp_manager.initialize_servers()
|
||||||
|
|
||||||
async def execute(
|
async def execute(
|
||||||
self, conn, tool_name: str, arguments: Dict[str, Any]
|
self, conn, tool_name: str, arguments: Dict[str, Any]
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ class ServerMCPManager:
|
|||||||
|
|
||||||
# 输出当前支持的服务端MCP工具列表
|
# 输出当前支持的服务端MCP工具列表
|
||||||
if hasattr(self.conn, "func_handler") and self.conn.func_handler:
|
if hasattr(self.conn, "func_handler") and self.conn.func_handler:
|
||||||
|
# 刷新工具缓存以确保服务端MCP工具被正确加载
|
||||||
|
if hasattr(self.conn.func_handler, "tool_manager"):
|
||||||
|
self.conn.func_handler.tool_manager.refresh_tools()
|
||||||
self.conn.func_handler.current_support_functions()
|
self.conn.func_handler.current_support_functions()
|
||||||
|
|
||||||
def get_all_tools(self) -> List[Dict[str, Any]]:
|
def get_all_tools(self) -> List[Dict[str, Any]]:
|
||||||
|
|||||||
@@ -216,7 +216,6 @@ class TTSProvider(TTSProviderBase):
|
|||||||
|
|
||||||
if message.sentence_type == SentenceType.FIRST:
|
if message.sentence_type == SentenceType.FIRST:
|
||||||
self.conn.client_abort = False
|
self.conn.client_abort = False
|
||||||
self.reset_flow_controller()
|
|
||||||
|
|
||||||
if self.conn.client_abort:
|
if self.conn.client_abort:
|
||||||
logger.bind(tag=TAG).info("收到打断信息,终止TTS文本处理线程")
|
logger.bind(tag=TAG).info("收到打断信息,终止TTS文本处理线程")
|
||||||
@@ -479,3 +478,142 @@ class TTSProvider(TTSProviderBase):
|
|||||||
finally:
|
finally:
|
||||||
self._monitor_task = None
|
self._monitor_task = None
|
||||||
|
|
||||||
|
def to_tts(self, text: str) -> list:
|
||||||
|
"""非流式TTS处理,用于测试及保存音频文件的场景"""
|
||||||
|
try:
|
||||||
|
# 创建新的事件循环
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
|
# 生成会话ID
|
||||||
|
session_id = uuid.uuid4().hex
|
||||||
|
# 存储音频数据
|
||||||
|
audio_data = []
|
||||||
|
|
||||||
|
async def _generate_audio():
|
||||||
|
# 刷新Token(如果需要)
|
||||||
|
if self._is_token_expired():
|
||||||
|
self._refresh_token()
|
||||||
|
|
||||||
|
# 建立WebSocket连接
|
||||||
|
ws = await websockets.connect(
|
||||||
|
self.ws_url,
|
||||||
|
additional_headers={"X-NLS-Token": self.token},
|
||||||
|
ping_interval=30,
|
||||||
|
ping_timeout=10,
|
||||||
|
close_timeout=10,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
# 发送StartSynthesis请求
|
||||||
|
start_message_id = str(uuid.uuid4().hex)
|
||||||
|
start_request = {
|
||||||
|
"header": {
|
||||||
|
"message_id": start_message_id,
|
||||||
|
"task_id": session_id,
|
||||||
|
"namespace": "FlowingSpeechSynthesizer",
|
||||||
|
"name": "StartSynthesis",
|
||||||
|
"appkey": self.appkey,
|
||||||
|
},
|
||||||
|
"payload": {
|
||||||
|
"voice": self.voice,
|
||||||
|
"format": self.format,
|
||||||
|
"sample_rate": self.sample_rate,
|
||||||
|
"volume": self.volume,
|
||||||
|
"speech_rate": self.speech_rate,
|
||||||
|
"pitch_rate": self.pitch_rate,
|
||||||
|
"enable_subtitle": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await ws.send(json.dumps(start_request))
|
||||||
|
|
||||||
|
# 等待SynthesisStarted响应
|
||||||
|
synthesis_started = False
|
||||||
|
while not synthesis_started:
|
||||||
|
msg = await ws.recv()
|
||||||
|
if isinstance(msg, str):
|
||||||
|
data = json.loads(msg)
|
||||||
|
header = data.get("header", {})
|
||||||
|
if header.get("name") == "SynthesisStarted":
|
||||||
|
synthesis_started = True
|
||||||
|
logger.bind(tag=TAG).debug("TTS合成已启动")
|
||||||
|
elif header.get("name") == "TaskFailed":
|
||||||
|
error_info = data.get("payload", {}).get(
|
||||||
|
"error_info", {}
|
||||||
|
)
|
||||||
|
error_code = error_info.get("error_code")
|
||||||
|
error_message = error_info.get(
|
||||||
|
"error_message", "未知错误"
|
||||||
|
)
|
||||||
|
raise Exception(
|
||||||
|
f"启动合成失败: {error_code} - {error_message}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 发送文本合成请求
|
||||||
|
filtered_text = MarkdownCleaner.clean_markdown(text)
|
||||||
|
run_message_id = str(uuid.uuid4().hex)
|
||||||
|
run_request = {
|
||||||
|
"header": {
|
||||||
|
"message_id": run_message_id,
|
||||||
|
"task_id": session_id,
|
||||||
|
"namespace": "FlowingSpeechSynthesizer",
|
||||||
|
"name": "RunSynthesis",
|
||||||
|
"appkey": self.appkey,
|
||||||
|
},
|
||||||
|
"payload": {"text": filtered_text},
|
||||||
|
}
|
||||||
|
await ws.send(json.dumps(run_request))
|
||||||
|
|
||||||
|
# 发送停止合成请求
|
||||||
|
stop_message_id = str(uuid.uuid4().hex)
|
||||||
|
stop_request = {
|
||||||
|
"header": {
|
||||||
|
"message_id": stop_message_id,
|
||||||
|
"task_id": session_id,
|
||||||
|
"namespace": "FlowingSpeechSynthesizer",
|
||||||
|
"name": "StopSynthesis",
|
||||||
|
"appkey": self.appkey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await ws.send(json.dumps(stop_request))
|
||||||
|
|
||||||
|
# 接收音频数据
|
||||||
|
synthesis_completed = False
|
||||||
|
while not synthesis_completed:
|
||||||
|
msg = await ws.recv()
|
||||||
|
if isinstance(msg, (bytes, bytearray)):
|
||||||
|
self.opus_encoder.encode_pcm_to_opus_stream(
|
||||||
|
msg,
|
||||||
|
end_of_stream=False,
|
||||||
|
callback=lambda opus: audio_data.append(opus)
|
||||||
|
)
|
||||||
|
elif isinstance(msg, str):
|
||||||
|
data = json.loads(msg)
|
||||||
|
header = data.get("header", {})
|
||||||
|
event_name = header.get("name")
|
||||||
|
if event_name == "SynthesisCompleted":
|
||||||
|
synthesis_completed = True
|
||||||
|
logger.bind(tag=TAG).debug("TTS合成完成")
|
||||||
|
elif event_name == "TaskFailed":
|
||||||
|
error_info = data.get("payload", {}).get(
|
||||||
|
"error_info", {}
|
||||||
|
)
|
||||||
|
error_code = error_info.get("error_code")
|
||||||
|
error_message = error_info.get(
|
||||||
|
"error_message", "未知错误"
|
||||||
|
)
|
||||||
|
raise Exception(
|
||||||
|
f"合成失败: {error_code} - {error_message}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
await ws.close()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
loop.run_until_complete(_generate_audio())
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
return audio_data
|
||||||
|
except Exception as e:
|
||||||
|
logger.bind(tag=TAG).error(f"生成音频数据失败: {str(e)}")
|
||||||
|
return []
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import queue
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
import queue
|
||||||
import asyncio
|
import asyncio
|
||||||
import threading
|
import threading
|
||||||
from typing import Callable, Any
|
import traceback
|
||||||
from core.utils import p3
|
from core.utils import p3
|
||||||
import time
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from core.utils import textUtils
|
from core.utils import textUtils
|
||||||
|
from typing import Callable, Any
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from config.logger import setup_logging
|
from config.logger import setup_logging
|
||||||
from core.utils.audio_flow_control import FlowControlConfig
|
|
||||||
from core.utils.util import audio_bytes_to_data_stream, audio_to_data_stream
|
|
||||||
from core.utils.tts import MarkdownCleaner
|
from core.utils.tts import MarkdownCleaner
|
||||||
from core.utils.output_counter import add_device_output
|
from core.utils.output_counter import add_device_output
|
||||||
from core.handle.reportHandle import enqueue_tts_report
|
from core.handle.reportHandle import enqueue_tts_report
|
||||||
from core.handle.sendAudioHandle import sendAudioMessage
|
from core.handle.sendAudioHandle import sendAudioMessage
|
||||||
|
from core.utils.util import audio_bytes_to_data_stream, audio_to_data_stream
|
||||||
from core.providers.tts.dto.dto import (
|
from core.providers.tts.dto.dto import (
|
||||||
TTSMessageDTO,
|
TTSMessageDTO,
|
||||||
SentenceType,
|
SentenceType,
|
||||||
@@ -24,8 +24,6 @@ from core.providers.tts.dto.dto import (
|
|||||||
InterfaceType,
|
InterfaceType,
|
||||||
)
|
)
|
||||||
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
logger = setup_logging()
|
logger = setup_logging()
|
||||||
|
|
||||||
@@ -34,7 +32,6 @@ class TTSProviderBase(ABC):
|
|||||||
def __init__(self, config, delete_audio_file):
|
def __init__(self, config, delete_audio_file):
|
||||||
self.interface_type = InterfaceType.NON_STREAM
|
self.interface_type = InterfaceType.NON_STREAM
|
||||||
self.conn = None
|
self.conn = None
|
||||||
self.tts_timeout = 10
|
|
||||||
self.delete_audio_file = delete_audio_file
|
self.delete_audio_file = delete_audio_file
|
||||||
self.audio_file_type = "wav"
|
self.audio_file_type = "wav"
|
||||||
self.output_file = config.get("output_dir", "tmp/")
|
self.output_file = config.get("output_dir", "tmp/")
|
||||||
@@ -71,7 +68,6 @@ class TTSProviderBase(ABC):
|
|||||||
self.tts_stop_request = False
|
self.tts_stop_request = False
|
||||||
self.processed_chars = 0
|
self.processed_chars = 0
|
||||||
self.is_first_sentence = True
|
self.is_first_sentence = True
|
||||||
self.flow_controller = FlowControlConfig.create_flow_controller()
|
|
||||||
|
|
||||||
def generate_filename(self, extension=".wav"):
|
def generate_filename(self, extension=".wav"):
|
||||||
return os.path.join(
|
return os.path.join(
|
||||||
@@ -147,6 +143,68 @@ class TTSProviderBase(ABC):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.bind(tag=TAG).error(f"Failed to generate TTS file: {e}")
|
logger.bind(tag=TAG).error(f"Failed to generate TTS file: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def to_tts(self, text):
|
||||||
|
text = MarkdownCleaner.clean_markdown(text)
|
||||||
|
max_repeat_time = 5
|
||||||
|
if self.delete_audio_file:
|
||||||
|
# 需要删除文件的直接转为音频数据
|
||||||
|
while max_repeat_time > 0:
|
||||||
|
try:
|
||||||
|
audio_bytes = asyncio.run(self.text_to_speak(text, None))
|
||||||
|
if audio_bytes:
|
||||||
|
audio_datas = []
|
||||||
|
audio_bytes_to_data_stream(
|
||||||
|
audio_bytes,
|
||||||
|
file_type=self.audio_file_type,
|
||||||
|
is_opus=True,
|
||||||
|
callback=lambda data: audio_datas.append(data)
|
||||||
|
)
|
||||||
|
return audio_datas
|
||||||
|
else:
|
||||||
|
max_repeat_time -= 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.bind(tag=TAG).warning(
|
||||||
|
f"语音生成失败{5 - max_repeat_time + 1}次: {text},错误: {e}"
|
||||||
|
)
|
||||||
|
max_repeat_time -= 1
|
||||||
|
if max_repeat_time > 0:
|
||||||
|
logger.bind(tag=TAG).info(
|
||||||
|
f"语音生成成功: {text},重试{5 - max_repeat_time}次"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.bind(tag=TAG).error(
|
||||||
|
f"语音生成失败: {text},请检查网络或服务是否正常"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
tmp_file = self.generate_filename()
|
||||||
|
try:
|
||||||
|
while not os.path.exists(tmp_file) and max_repeat_time > 0:
|
||||||
|
try:
|
||||||
|
asyncio.run(self.text_to_speak(text, tmp_file))
|
||||||
|
except Exception as e:
|
||||||
|
logger.bind(tag=TAG).warning(
|
||||||
|
f"语音生成失败{5 - max_repeat_time + 1}次: {text},错误: {e}"
|
||||||
|
)
|
||||||
|
# 未执行成功,删除文件
|
||||||
|
if os.path.exists(tmp_file):
|
||||||
|
os.remove(tmp_file)
|
||||||
|
max_repeat_time -= 1
|
||||||
|
|
||||||
|
if max_repeat_time > 0:
|
||||||
|
logger.bind(tag=TAG).info(
|
||||||
|
f"语音生成成功: {text}:{tmp_file},重试{5 - max_repeat_time}次"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.bind(tag=TAG).error(
|
||||||
|
f"语音生成失败: {text},请检查网络或服务是否正常"
|
||||||
|
)
|
||||||
|
|
||||||
|
return tmp_file
|
||||||
|
except Exception as e:
|
||||||
|
logger.bind(tag=TAG).error(f"Failed to generate TTS file: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def text_to_speak(self, text, output_file):
|
async def text_to_speak(self, text, output_file):
|
||||||
@@ -194,7 +252,6 @@ class TTSProviderBase(ABC):
|
|||||||
|
|
||||||
async def open_audio_channels(self, conn):
|
async def open_audio_channels(self, conn):
|
||||||
self.conn = conn
|
self.conn = conn
|
||||||
self.tts_timeout = conn.config.get("tts_timeout", 10)
|
|
||||||
# tts 消化线程
|
# tts 消化线程
|
||||||
self.tts_priority_thread = threading.Thread(
|
self.tts_priority_thread = threading.Thread(
|
||||||
target=self.tts_text_priority_thread, daemon=True
|
target=self.tts_text_priority_thread, daemon=True
|
||||||
@@ -225,7 +282,6 @@ class TTSProviderBase(ABC):
|
|||||||
self.tts_text_buff = []
|
self.tts_text_buff = []
|
||||||
self.is_first_sentence = True
|
self.is_first_sentence = True
|
||||||
self.tts_audio_first_sentence = True
|
self.tts_audio_first_sentence = True
|
||||||
self.reset_flow_controller()
|
|
||||||
elif ContentType.TEXT == message.content_type:
|
elif ContentType.TEXT == message.content_type:
|
||||||
self.tts_text_buff.append(message.content_detail)
|
self.tts_text_buff.append(message.content_detail)
|
||||||
segment_text = self._get_segment_text()
|
segment_text = self._get_segment_text()
|
||||||
@@ -270,103 +326,43 @@ class TTSProviderBase(ABC):
|
|||||||
|
|
||||||
if self.conn.client_abort:
|
if self.conn.client_abort:
|
||||||
logger.bind(tag=TAG).debug("收到打断信号,跳过当前音频数据")
|
logger.bind(tag=TAG).debug("收到打断信号,跳过当前音频数据")
|
||||||
# 打断时丢弃未上报的音频数据
|
|
||||||
enqueue_text, enqueue_audio = None, []
|
enqueue_text, enqueue_audio = None, []
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 收到下一个文本开始或会话结束时进行上报
|
# 收到下一个文本开始或会话结束时进行上报
|
||||||
if sentence_type is not SentenceType.MIDDLE:
|
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数据
|
# 上报TTS数据
|
||||||
if enqueue_text is not None and enqueue_audio is not None:
|
if enqueue_text is not None and enqueue_audio is not None:
|
||||||
enqueue_tts_report(self.conn, enqueue_text, enqueue_audio)
|
enqueue_tts_report(self.conn, enqueue_text, enqueue_audio)
|
||||||
enqueue_audio = []
|
enqueue_audio = []
|
||||||
enqueue_text = text
|
enqueue_text = text
|
||||||
|
|
||||||
# 计算音频数据的帧数
|
# 收集上报音频数据
|
||||||
if isinstance(audio_datas, bytes):
|
if isinstance(audio_datas, bytes) and enqueue_audio is not None:
|
||||||
frame_count = 1 # 单个字节流作为一帧
|
|
||||||
enqueue_audio.append(audio_datas)
|
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:
|
if self.conn.max_output_size > 0 and text:
|
||||||
add_device_output(self.conn.headers.get("device-id"), len(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:
|
except Exception as e:
|
||||||
logger.bind(tag=TAG).error(f"audio_play_priority_thread: {text} {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):
|
|
||||||
# 模拟设备播放延迟(60ms per frame), 实际情况可以低一点(50ms),增加使用体验
|
|
||||||
await asyncio.sleep(0.06)
|
|
||||||
self.flow_controller.update_device_consumption(1)
|
|
||||||
|
|
||||||
# 在类中添加流控制器重置方法
|
|
||||||
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):
|
async def start_session(self, session_id):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -143,8 +143,8 @@ class TTSProvider(TTSProviderBase):
|
|||||||
data = {
|
data = {
|
||||||
"text": text,
|
"text": text,
|
||||||
"references": [
|
"references": [
|
||||||
ServeReferenceAudio(audio=audio if audio else b"", text=text)
|
ServeReferenceAudio(audio=audio if audio else b"", text=ref_text)
|
||||||
for text, audio in zip(ref_texts, byte_audios)
|
for ref_text, audio in zip(ref_texts, byte_audios)
|
||||||
],
|
],
|
||||||
"reference_id": self.reference_id,
|
"reference_id": self.reference_id,
|
||||||
"normalize": self.normalize,
|
"normalize": self.normalize,
|
||||||
|
|||||||
@@ -213,7 +213,6 @@ class TTSProvider(TTSProviderBase):
|
|||||||
|
|
||||||
if message.sentence_type == SentenceType.FIRST:
|
if message.sentence_type == SentenceType.FIRST:
|
||||||
self.conn.client_abort = False
|
self.conn.client_abort = False
|
||||||
self.reset_flow_controller()
|
|
||||||
|
|
||||||
if self.conn.client_abort:
|
if self.conn.client_abort:
|
||||||
try:
|
try:
|
||||||
@@ -629,3 +628,104 @@ class TTSProvider(TTSProviderBase):
|
|||||||
|
|
||||||
def wav_to_opus_data_audio_raw_stream(self, raw_data_var, is_end=False, callback: Callable[[Any], Any]=None):
|
def wav_to_opus_data_audio_raw_stream(self, raw_data_var, is_end=False, callback: Callable[[Any], Any]=None):
|
||||||
return self.opus_encoder.encode_pcm_to_opus_stream(raw_data_var, is_end, callback=callback)
|
return self.opus_encoder.encode_pcm_to_opus_stream(raw_data_var, is_end, callback=callback)
|
||||||
|
|
||||||
|
def to_tts(self, text: str) -> list:
|
||||||
|
"""非流式生成音频数据,用于生成音频及测试场景
|
||||||
|
Args:
|
||||||
|
text: 要转换的文本
|
||||||
|
Returns:
|
||||||
|
list: 音频数据列表
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 创建事件循环
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
|
# 生成会话ID
|
||||||
|
session_id = uuid.uuid4().__str__().replace("-", "")
|
||||||
|
|
||||||
|
# 存储音频数据
|
||||||
|
audio_data = []
|
||||||
|
|
||||||
|
async def _generate_audio():
|
||||||
|
# 创建新的WebSocket连接
|
||||||
|
ws_header = {
|
||||||
|
"X-Api-App-Key": self.appId,
|
||||||
|
"X-Api-Access-Key": self.access_token,
|
||||||
|
"X-Api-Resource-Id": self.resource_id,
|
||||||
|
"X-Api-Connect-Id": uuid.uuid4(),
|
||||||
|
}
|
||||||
|
ws = await websockets.connect(
|
||||||
|
self.ws_url, additional_headers=ws_header, max_size=1000000000
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 启动会话
|
||||||
|
header = Header(
|
||||||
|
message_type=FULL_CLIENT_REQUEST,
|
||||||
|
message_type_specific_flags=MsgTypeFlagWithEvent,
|
||||||
|
serial_method=JSON,
|
||||||
|
).as_bytes()
|
||||||
|
optional = Optional(
|
||||||
|
event=EVENT_StartSession, sessionId=session_id
|
||||||
|
).as_bytes()
|
||||||
|
payload = self.get_payload_bytes(
|
||||||
|
event=EVENT_StartSession, speaker=self.voice
|
||||||
|
)
|
||||||
|
await self.send_event(ws, header, optional, payload)
|
||||||
|
|
||||||
|
# 发送文本
|
||||||
|
header = Header(
|
||||||
|
message_type=FULL_CLIENT_REQUEST,
|
||||||
|
message_type_specific_flags=MsgTypeFlagWithEvent,
|
||||||
|
serial_method=JSON,
|
||||||
|
).as_bytes()
|
||||||
|
optional = Optional(
|
||||||
|
event=EVENT_TaskRequest, sessionId=session_id
|
||||||
|
).as_bytes()
|
||||||
|
payload = self.get_payload_bytes(
|
||||||
|
event=EVENT_TaskRequest, text=text, speaker=self.voice
|
||||||
|
)
|
||||||
|
await self.send_event(ws, header, optional, payload)
|
||||||
|
|
||||||
|
# 发送结束会话请求
|
||||||
|
header = Header(
|
||||||
|
message_type=FULL_CLIENT_REQUEST,
|
||||||
|
message_type_specific_flags=MsgTypeFlagWithEvent,
|
||||||
|
serial_method=JSON,
|
||||||
|
).as_bytes()
|
||||||
|
optional = Optional(
|
||||||
|
event=EVENT_FinishSession, sessionId=session_id
|
||||||
|
).as_bytes()
|
||||||
|
payload = str.encode("{}")
|
||||||
|
await self.send_event(ws, header, optional, payload)
|
||||||
|
|
||||||
|
# 接收音频数据
|
||||||
|
while True:
|
||||||
|
msg = await ws.recv()
|
||||||
|
res = self.parser_response(msg)
|
||||||
|
|
||||||
|
if (
|
||||||
|
res.optional.event == EVENT_TTSResponse
|
||||||
|
and res.header.message_type == AUDIO_ONLY_RESPONSE
|
||||||
|
):
|
||||||
|
self.wav_to_opus_data_audio_raw_stream(res.payload, callback=lambda opus_frame: audio_data.append(opus_frame))
|
||||||
|
elif res.optional.event == EVENT_SessionFinished:
|
||||||
|
break
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# 清理资源
|
||||||
|
try:
|
||||||
|
await ws.close()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 运行异步任务
|
||||||
|
loop.run_until_complete(_generate_audio())
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
return audio_data
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.bind(tag=TAG).error(f"生成音频数据失败: {str(e)}")
|
||||||
|
return []
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import os
|
import os
|
||||||
|
import time
|
||||||
import queue
|
import queue
|
||||||
import asyncio
|
|
||||||
import traceback
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
import asyncio
|
||||||
|
import requests
|
||||||
|
import traceback
|
||||||
from config.logger import setup_logging
|
from config.logger import setup_logging
|
||||||
from core.utils.tts import MarkdownCleaner
|
from core.utils.tts import MarkdownCleaner
|
||||||
from core.providers.tts.base import TTSProviderBase
|
from core.providers.tts.base import TTSProviderBase
|
||||||
@@ -45,7 +47,6 @@ class TTSProvider(TTSProviderBase):
|
|||||||
self.processed_chars = 0
|
self.processed_chars = 0
|
||||||
self.tts_text_buff = []
|
self.tts_text_buff = []
|
||||||
self.before_stop_play_files.clear()
|
self.before_stop_play_files.clear()
|
||||||
self.reset_flow_controller()
|
|
||||||
elif ContentType.TEXT == message.content_type:
|
elif ContentType.TEXT == message.content_type:
|
||||||
self.tts_text_buff.append(message.content_detail)
|
self.tts_text_buff.append(message.content_detail)
|
||||||
segment_text = self._get_segment_text()
|
segment_text = self._get_segment_text()
|
||||||
@@ -178,3 +179,57 @@ class TTSProvider(TTSProviderBase):
|
|||||||
await super().close()
|
await super().close()
|
||||||
if hasattr(self, "opus_encoder"):
|
if hasattr(self, "opus_encoder"):
|
||||||
self.opus_encoder.close()
|
self.opus_encoder.close()
|
||||||
|
|
||||||
|
def to_tts(self, text: str) -> list:
|
||||||
|
"""非流式TTS处理,用于测试及保存音频文件的场景
|
||||||
|
Args:
|
||||||
|
text: 要转换的文本
|
||||||
|
Returns:
|
||||||
|
list: 返回opus编码后的音频数据列表
|
||||||
|
"""
|
||||||
|
start_time = time.time()
|
||||||
|
text = MarkdownCleaner.clean_markdown(text)
|
||||||
|
|
||||||
|
payload = {"text": text, "character": self.character}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with requests.post(self.api_url, json=payload, timeout=5) as response:
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.bind(tag=TAG).error(
|
||||||
|
f"TTS请求失败: {response.status_code}, {response.text}"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
logger.info(f"TTS请求成功: {text}, 耗时: {time.time() - start_time}秒")
|
||||||
|
|
||||||
|
# 使用opus编码器处理PCM数据
|
||||||
|
opus_datas = []
|
||||||
|
pcm_data = response.content
|
||||||
|
|
||||||
|
# 计算每帧的字节数
|
||||||
|
frame_bytes = int(
|
||||||
|
self.opus_encoder.sample_rate
|
||||||
|
* self.opus_encoder.channels
|
||||||
|
* self.opus_encoder.frame_size_ms
|
||||||
|
/ 1000
|
||||||
|
* 2
|
||||||
|
)
|
||||||
|
|
||||||
|
# 分帧处理PCM数据
|
||||||
|
for i in range(0, len(pcm_data), frame_bytes):
|
||||||
|
frame = pcm_data[i : i + frame_bytes]
|
||||||
|
if len(frame) < frame_bytes:
|
||||||
|
# 最后一帧可能不足,用0填充
|
||||||
|
frame = frame + b"\x00" * (frame_bytes - len(frame))
|
||||||
|
|
||||||
|
self.opus_encoder.encode_pcm_to_opus_stream(
|
||||||
|
frame,
|
||||||
|
end_of_stream=(i + frame_bytes >= len(pcm_data)),
|
||||||
|
callback=lambda opus: opus_datas.append(opus)
|
||||||
|
)
|
||||||
|
|
||||||
|
return opus_datas
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.bind(tag=TAG).error(f"TTS请求异常: {e}")
|
||||||
|
return []
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import os
|
import os
|
||||||
|
import time
|
||||||
import queue
|
import queue
|
||||||
import asyncio
|
|
||||||
import traceback
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
import asyncio
|
||||||
|
import requests
|
||||||
|
import traceback
|
||||||
from config.logger import setup_logging
|
from config.logger import setup_logging
|
||||||
from core.utils.tts import MarkdownCleaner
|
from core.utils.tts import MarkdownCleaner
|
||||||
from core.providers.tts.base import TTSProviderBase
|
from core.providers.tts.base import TTSProviderBase
|
||||||
@@ -42,7 +44,6 @@ class TTSProvider(TTSProviderBase):
|
|||||||
self.processed_chars = 0
|
self.processed_chars = 0
|
||||||
self.tts_text_buff = []
|
self.tts_text_buff = []
|
||||||
self.before_stop_play_files.clear()
|
self.before_stop_play_files.clear()
|
||||||
self.reset_flow_controller()
|
|
||||||
elif ContentType.TEXT == message.content_type:
|
elif ContentType.TEXT == message.content_type:
|
||||||
self.tts_text_buff.append(message.content_detail)
|
self.tts_text_buff.append(message.content_detail)
|
||||||
segment_text = self._get_segment_text()
|
segment_text = self._get_segment_text()
|
||||||
@@ -110,10 +111,6 @@ class TTSProvider(TTSProviderBase):
|
|||||||
finally:
|
finally:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
###################################################################################
|
|
||||||
# linkerai单流式TTS重写父类的方法--结束
|
|
||||||
###################################################################################
|
|
||||||
|
|
||||||
async def text_to_speak(self, text, is_last):
|
async def text_to_speak(self, text, is_last):
|
||||||
"""流式处理TTS音频,每句只推送一次音频列表"""
|
"""流式处理TTS音频,每句只推送一次音频列表"""
|
||||||
await self._tts_request(text, is_last)
|
await self._tts_request(text, is_last)
|
||||||
@@ -200,3 +197,71 @@ class TTSProvider(TTSProviderBase):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.bind(tag=TAG).error(f"TTS请求异常: {e}")
|
logger.bind(tag=TAG).error(f"TTS请求异常: {e}")
|
||||||
self.tts_audio_queue.put((SentenceType.LAST, [], None))
|
self.tts_audio_queue.put((SentenceType.LAST, [], None))
|
||||||
|
|
||||||
|
def to_tts(self, text: str) -> list:
|
||||||
|
"""非流式TTS处理,用于测试及保存音频文件的场景
|
||||||
|
Args:
|
||||||
|
text: 要转换的文本
|
||||||
|
Returns:
|
||||||
|
list: 返回opus编码后的音频数据列表
|
||||||
|
"""
|
||||||
|
start_time = time.time()
|
||||||
|
text = MarkdownCleaner.clean_markdown(text)
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"tts_text": text,
|
||||||
|
"spk_id": self.voice,
|
||||||
|
"frame_duration": 60,
|
||||||
|
"stream": False,
|
||||||
|
"target_sr": 16000,
|
||||||
|
"audio_format": self.audio_format,
|
||||||
|
"instruct_text": "请生成一段自然流畅的语音",
|
||||||
|
}
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {self.access_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with requests.get(
|
||||||
|
self.api_url, params=params, headers=headers, timeout=5
|
||||||
|
) as response:
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.bind(tag=TAG).error(
|
||||||
|
f"TTS请求失败: {response.status_code}, {response.text}"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
logger.info(f"TTS请求成功: {text}, 耗时: {time.time() - start_time}秒")
|
||||||
|
|
||||||
|
# 使用opus编码器处理PCM数据
|
||||||
|
opus_datas = []
|
||||||
|
pcm_data = response.content
|
||||||
|
|
||||||
|
# 计算每帧的字节数
|
||||||
|
frame_bytes = int(
|
||||||
|
self.opus_encoder.sample_rate
|
||||||
|
* self.opus_encoder.channels
|
||||||
|
* self.opus_encoder.frame_size_ms
|
||||||
|
/ 1000
|
||||||
|
* 2
|
||||||
|
)
|
||||||
|
|
||||||
|
# 分帧处理PCM数据
|
||||||
|
for i in range(0, len(pcm_data), frame_bytes):
|
||||||
|
frame = pcm_data[i : i + frame_bytes]
|
||||||
|
if len(frame) < frame_bytes:
|
||||||
|
# 最后一帧可能不足,用0填充
|
||||||
|
frame = frame + b"\x00" * (frame_bytes - len(frame))
|
||||||
|
|
||||||
|
self.opus_encoder.encode_pcm_to_opus_stream(
|
||||||
|
frame,
|
||||||
|
end_of_stream=(i + frame_bytes >= len(pcm_data)),
|
||||||
|
callback=lambda opus: opus_datas.append(opus)
|
||||||
|
)
|
||||||
|
|
||||||
|
return opus_datas
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.bind(tag=TAG).error(f"TTS请求异常: {e}")
|
||||||
|
return []
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import base64
|
|
||||||
import aiohttp
|
|
||||||
import numpy as np
|
|
||||||
import io
|
import io
|
||||||
import wave
|
import wave
|
||||||
|
import json
|
||||||
|
import base64
|
||||||
|
import asyncio
|
||||||
import websockets
|
import websockets
|
||||||
from core.providers.tts.base import TTSProviderBase
|
import numpy as np
|
||||||
|
from datetime import datetime
|
||||||
from config.logger import setup_logging
|
from config.logger import setup_logging
|
||||||
|
from core.providers.tts.base import TTSProviderBase
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
logger = setup_logging()
|
logger = setup_logging()
|
||||||
@@ -18,11 +20,12 @@ class TTSProvider(TTSProviderBase):
|
|||||||
super().__init__(config, delete_audio_file)
|
super().__init__(config, delete_audio_file)
|
||||||
self.url = config.get("url", "ws://192.168.1.10:8092/paddlespeech/tts/streaming")
|
self.url = config.get("url", "ws://192.168.1.10:8092/paddlespeech/tts/streaming")
|
||||||
self.protocol = config.get("protocol", "websocket")
|
self.protocol = config.get("protocol", "websocket")
|
||||||
|
|
||||||
if config.get("private_voice"):
|
if config.get("private_voice"):
|
||||||
self.spk_id = int(config.get("private_voice"))
|
self.spk_id = int(config.get("private_voice"))
|
||||||
else:
|
else:
|
||||||
self.spk_id = int(config.get("spk_id", "0"))
|
self.spk_id = int(config.get("spk_id", "0"))
|
||||||
|
|
||||||
sample_rate = config.get("sample_rate", 24000)
|
sample_rate = config.get("sample_rate", 24000)
|
||||||
self.sample_rate = float(sample_rate) if sample_rate else 24000
|
self.sample_rate = float(sample_rate) if sample_rate else 24000
|
||||||
|
|
||||||
@@ -32,7 +35,21 @@ class TTSProvider(TTSProviderBase):
|
|||||||
volume = config.get("volume", 1.0)
|
volume = config.get("volume", 1.0)
|
||||||
self.volume = float(volume) if volume else 1.0
|
self.volume = float(volume) if volume else 1.0
|
||||||
|
|
||||||
self.save_path = config.get("save_path", "./streaming_tts.wav")
|
self.delete_audio_file = config.get("delete_audio", True)
|
||||||
|
if not self.delete_audio_file:
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
save_path = config.get("save_path")
|
||||||
|
if save_path:
|
||||||
|
if not save_path.endswith('.wav'):
|
||||||
|
save_path = f"{save_path}_{timestamp}.wav"
|
||||||
|
else:
|
||||||
|
other_path = save_path[:-4]
|
||||||
|
save_path = f"{other_path}_{timestamp}.wav"
|
||||||
|
self.save_path = save_path
|
||||||
|
else:
|
||||||
|
self.save_path = f"./streaming_tts_{timestamp}.wav"
|
||||||
|
else:
|
||||||
|
self.save_path = None
|
||||||
|
|
||||||
async def pcm_to_wav(self, pcm_data: bytes, sample_rate: int = 24000, num_channels: int = 1,
|
async def pcm_to_wav(self, pcm_data: bytes, sample_rate: int = 24000, num_channels: int = 1,
|
||||||
bits_per_sample: int = 16) -> bytes:
|
bits_per_sample: int = 16) -> bytes:
|
||||||
@@ -58,43 +75,9 @@ class TTSProvider(TTSProviderBase):
|
|||||||
async def text_to_speak(self, text, output_file):
|
async def text_to_speak(self, text, output_file):
|
||||||
if self.protocol == "websocket":
|
if self.protocol == "websocket":
|
||||||
return await self.text_streaming(text, output_file)
|
return await self.text_streaming(text, output_file)
|
||||||
elif self.protocol == "http":
|
|
||||||
return await self.text(text, output_file)
|
|
||||||
else:
|
else:
|
||||||
raise ValueError("Unsupported protocol. Please use 'websocket' or 'http'.")
|
raise ValueError("Unsupported protocol. Please use 'websocket' or 'http'.")
|
||||||
|
|
||||||
async def text(self, text, output_file):
|
|
||||||
request_json = {
|
|
||||||
"text": text,
|
|
||||||
"spk_id": self.spk_id,
|
|
||||||
"speed": self.speed,
|
|
||||||
"volume": self.volume,
|
|
||||||
"sample_rate": self.sample_rate,
|
|
||||||
"save_path": self.save_path
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
async with session.post(self.url, json=request_json) as resp:
|
|
||||||
if resp.status == 200:
|
|
||||||
resp_json = await resp.json()
|
|
||||||
if resp_json.get("success"):
|
|
||||||
data = resp_json["result"]
|
|
||||||
audio_bytes = base64.b64decode(data["audio"])
|
|
||||||
if output_file:
|
|
||||||
with open(output_file, "wb") as file_to_save:
|
|
||||||
file_to_save.write(audio_bytes)
|
|
||||||
else:
|
|
||||||
return audio_bytes
|
|
||||||
else:
|
|
||||||
raise Exception(
|
|
||||||
f"Error: {resp_json.get('message', 'Unknown error')} while processing text: {text}")
|
|
||||||
else:
|
|
||||||
raise Exception(
|
|
||||||
f"HTTP Error: {resp.status} - {await resp.text()} while processing text: {text}")
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception(f"Error during TTS HTTP request: {e} while processing text: {text}")
|
|
||||||
|
|
||||||
async def text_streaming(self, text, output_file):
|
async def text_streaming(self, text, output_file):
|
||||||
try:
|
try:
|
||||||
# 使用 websockets 异步连接到 WebSocket 服务器
|
# 使用 websockets 异步连接到 WebSocket 服务器
|
||||||
@@ -151,6 +134,12 @@ class TTSProvider(TTSProviderBase):
|
|||||||
# 接收结束响应避免服务抛出异常
|
# 接收结束响应避免服务抛出异常
|
||||||
await ws.recv()
|
await ws.recv()
|
||||||
|
|
||||||
|
# 根据配置决定是否保存文件
|
||||||
|
if not self.delete_audio_file and self.save_path:
|
||||||
|
with open(self.save_path, "wb") as f:
|
||||||
|
f.write(wav_data)
|
||||||
|
logger.bind(tag=TAG).info(f"音频文件已保存到: {self.save_path}")
|
||||||
|
|
||||||
# 返回或保存音频数据
|
# 返回或保存音频数据
|
||||||
if output_file:
|
if output_file:
|
||||||
with open(output_file, "wb") as file_to_save:
|
with open(output_file, "wb") as file_to_save:
|
||||||
@@ -159,4 +148,4 @@ class TTSProvider(TTSProviderBase):
|
|||||||
return wav_data
|
return wav_data
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Error during TTS WebSocket request: {e} while processing text: {text}")
|
raise Exception(f"Error during TTS WebSocket request: {e} while processing text: {text}")
|
||||||
@@ -1,186 +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
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# 流控配置常量
|
|
||||||
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
|
|
||||||
)
|
|
||||||
@@ -6,10 +6,9 @@ Opus编码工具类
|
|||||||
import logging
|
import logging
|
||||||
import traceback
|
import traceback
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from typing import Optional, Callable, Any
|
|
||||||
from opuslib_next import Encoder
|
from opuslib_next import Encoder
|
||||||
from opuslib_next import constants
|
from opuslib_next import constants
|
||||||
|
from typing import Optional, Callable, Any
|
||||||
|
|
||||||
class OpusEncoderUtils:
|
class OpusEncoderUtils:
|
||||||
"""PCM到Opus的编码器"""
|
"""PCM到Opus的编码器"""
|
||||||
@@ -130,4 +129,4 @@ class OpusEncoderUtils:
|
|||||||
def close(self):
|
def close(self):
|
||||||
"""关闭编码器并释放资源"""
|
"""关闭编码器并释放资源"""
|
||||||
# opuslib没有明确的关闭方法,Python的垃圾回收会处理
|
# opuslib没有明确的关闭方法,Python的垃圾回收会处理
|
||||||
pass
|
pass
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import io
|
|
||||||
import struct
|
import struct
|
||||||
from typing import Callable, Any
|
|
||||||
|
|
||||||
|
def decode_opus_from_file(input_file):
|
||||||
|
"""
|
||||||
|
从p3文件中解码 Opus 数据,并返回一个 Opus 数据包的列表以及总时长。
|
||||||
|
"""
|
||||||
|
opus_datas = []
|
||||||
|
total_frames = 0
|
||||||
|
sample_rate = 16000 # 文件采样率
|
||||||
|
frame_duration_ms = 60 # 帧时长
|
||||||
|
frame_size = int(sample_rate * frame_duration_ms / 1000)
|
||||||
|
|
||||||
def decode_opus_from_file_stream(input_file, callback: Callable[[Any], Any]):
|
|
||||||
"""
|
|
||||||
从p3文件中解码 Opus 数据,由 callback 处理 Opus 数据包。
|
|
||||||
"""
|
|
||||||
with open(input_file, 'rb') as f:
|
with open(input_file, 'rb') as f:
|
||||||
while True:
|
while True:
|
||||||
# 读取头部(4字节):[1字节类型,1字节保留,2字节长度]
|
# 读取头部(4字节):[1字节类型,1字节保留,2字节长度]
|
||||||
@@ -22,13 +25,23 @@ def decode_opus_from_file_stream(input_file, callback: Callable[[Any], Any]):
|
|||||||
if len(opus_data) != data_len:
|
if len(opus_data) != data_len:
|
||||||
raise ValueError(f"Data length({len(opus_data)}) mismatch({data_len}) in the file.")
|
raise ValueError(f"Data length({len(opus_data)}) mismatch({data_len}) in the file.")
|
||||||
|
|
||||||
callback(opus_data)
|
opus_datas.append(opus_data)
|
||||||
|
total_frames += 1
|
||||||
|
|
||||||
|
# 计算总时长
|
||||||
|
total_duration = (total_frames * frame_duration_ms) / 1000.0
|
||||||
|
return opus_datas, total_duration
|
||||||
|
|
||||||
def decode_opus_from_bytes_stream(input_bytes, callback: Callable[[Any], Any]):
|
def decode_opus_from_bytes(input_bytes):
|
||||||
"""
|
"""
|
||||||
从p3二进制数据中解码 Opus 数据,由 callback 处理 Opus 数据包。
|
从p3二进制数据中解码 Opus 数据,并返回一个 Opus 数据包的列表以及总时长。
|
||||||
"""
|
"""
|
||||||
|
import io
|
||||||
|
opus_datas = []
|
||||||
|
total_frames = 0
|
||||||
|
sample_rate = 16000 # 文件采样率
|
||||||
|
frame_duration_ms = 60 # 帧时长
|
||||||
|
frame_size = int(sample_rate * frame_duration_ms / 1000)
|
||||||
|
|
||||||
f = io.BytesIO(input_bytes)
|
f = io.BytesIO(input_bytes)
|
||||||
while True:
|
while True:
|
||||||
@@ -39,4 +52,8 @@ def decode_opus_from_bytes_stream(input_bytes, callback: Callable[[Any], Any]):
|
|||||||
opus_data = f.read(data_len)
|
opus_data = f.read(data_len)
|
||||||
if len(opus_data) != data_len:
|
if len(opus_data) != data_len:
|
||||||
raise ValueError(f"Data length({len(opus_data)}) mismatch({data_len}) in the bytes.")
|
raise ValueError(f"Data length({len(opus_data)}) mismatch({data_len}) in the bytes.")
|
||||||
callback(opus_data)
|
opus_datas.append(opus_data)
|
||||||
|
total_frames += 1
|
||||||
|
|
||||||
|
total_duration = (total_frames * frame_duration_ms) / 1000.0
|
||||||
|
return opus_datas, total_duration
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
import json
|
|
||||||
import socket
|
|
||||||
import subprocess
|
|
||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
from io import BytesIO
|
import json
|
||||||
from typing import Callable, Any
|
|
||||||
from core.utils import p3
|
|
||||||
import numpy as np
|
|
||||||
import requests
|
|
||||||
import opuslib_next
|
|
||||||
from pydub import AudioSegment
|
|
||||||
import copy
|
import copy
|
||||||
|
import wave
|
||||||
|
import socket
|
||||||
|
import requests
|
||||||
|
import subprocess
|
||||||
|
import numpy as np
|
||||||
|
import opuslib_next
|
||||||
|
from io import BytesIO
|
||||||
|
from core.utils import p3
|
||||||
|
from pydub import AudioSegment
|
||||||
|
from typing import Callable, Any
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
emoji_map = {
|
emoji_map = {
|
||||||
@@ -228,6 +229,56 @@ def audio_to_data_stream(audio_file_path, is_opus=True, callback: Callable[[Any]
|
|||||||
raw_data = audio.raw_data
|
raw_data = audio.raw_data
|
||||||
pcm_to_data_stream(raw_data, is_opus, callback)
|
pcm_to_data_stream(raw_data, is_opus, callback)
|
||||||
|
|
||||||
|
def audio_to_data(audio_file_path: str, is_opus: bool = True) -> list[bytes]:
|
||||||
|
"""
|
||||||
|
将音频文件转换为Opus/PCM编码的帧列表
|
||||||
|
Args:
|
||||||
|
audio_file_path: 音频文件路径
|
||||||
|
is_opus: 是否进行Opus编码
|
||||||
|
"""
|
||||||
|
# 获取文件后缀名
|
||||||
|
file_type = os.path.splitext(audio_file_path)[1]
|
||||||
|
if file_type:
|
||||||
|
file_type = file_type.lstrip(".")
|
||||||
|
# 读取音频文件,-nostdin 参数:不要从标准输入读取数据,否则FFmpeg会阻塞
|
||||||
|
audio = AudioSegment.from_file(
|
||||||
|
audio_file_path, format=file_type, parameters=["-nostdin"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# 转换为单声道/16kHz采样率/16位小端编码(确保与编码器匹配)
|
||||||
|
audio = audio.set_channels(1).set_frame_rate(16000).set_sample_width(2)
|
||||||
|
|
||||||
|
# 获取原始PCM数据(16位小端)
|
||||||
|
raw_data = audio.raw_data
|
||||||
|
|
||||||
|
# 初始化Opus编码器
|
||||||
|
encoder = opuslib_next.Encoder(16000, 1, opuslib_next.APPLICATION_AUDIO)
|
||||||
|
|
||||||
|
# 编码参数
|
||||||
|
frame_duration = 60 # 60ms per frame
|
||||||
|
frame_size = int(16000 * frame_duration / 1000) # 960 samples/frame
|
||||||
|
|
||||||
|
datas = []
|
||||||
|
# 按帧处理所有音频数据(包括最后一帧可能补零)
|
||||||
|
for i in range(0, len(raw_data), frame_size * 2): # 16bit=2bytes/sample
|
||||||
|
# 获取当前帧的二进制数据
|
||||||
|
chunk = raw_data[i : i + frame_size * 2]
|
||||||
|
|
||||||
|
# 如果最后一帧不足,补零
|
||||||
|
if len(chunk) < frame_size * 2:
|
||||||
|
chunk += b"\x00" * (frame_size * 2 - len(chunk))
|
||||||
|
|
||||||
|
if is_opus:
|
||||||
|
# 转换为numpy数组处理
|
||||||
|
np_frame = np.frombuffer(chunk, dtype=np.int16)
|
||||||
|
# 编码Opus数据
|
||||||
|
frame_data = encoder.encode(np_frame.tobytes(), frame_size)
|
||||||
|
else:
|
||||||
|
frame_data = chunk if isinstance(chunk, bytes) else bytes(chunk)
|
||||||
|
|
||||||
|
datas.append(frame_data)
|
||||||
|
|
||||||
|
return datas
|
||||||
|
|
||||||
def audio_bytes_to_data_stream(audio_bytes, file_type, is_opus, callback: Callable[[Any], Any]) -> None:
|
def audio_bytes_to_data_stream(audio_bytes, file_type, is_opus, callback: Callable[[Any], Any]) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -273,6 +324,31 @@ def pcm_to_data_stream(raw_data, is_opus=True, callback: Callable[[Any], Any] =
|
|||||||
frame_data = chunk if isinstance(chunk, bytes) else bytes(chunk)
|
frame_data = chunk if isinstance(chunk, bytes) else bytes(chunk)
|
||||||
callback(frame_data)
|
callback(frame_data)
|
||||||
|
|
||||||
|
def opus_datas_to_wav_bytes(opus_datas, sample_rate=16000, channels=1):
|
||||||
|
"""
|
||||||
|
将opus帧列表解码为wav字节流
|
||||||
|
"""
|
||||||
|
decoder = opuslib_next.Decoder(sample_rate, channels)
|
||||||
|
pcm_datas = []
|
||||||
|
|
||||||
|
frame_duration = 60 # ms
|
||||||
|
frame_size = int(sample_rate * frame_duration / 1000) # 960
|
||||||
|
|
||||||
|
for opus_frame in opus_datas:
|
||||||
|
# 解码为PCM(返回bytes,2字节/采样点)
|
||||||
|
pcm = decoder.decode(opus_frame, frame_size)
|
||||||
|
pcm_datas.append(pcm)
|
||||||
|
|
||||||
|
pcm_bytes = b"".join(pcm_datas)
|
||||||
|
|
||||||
|
# 写入wav字节流
|
||||||
|
wav_buffer = BytesIO()
|
||||||
|
with wave.open(wav_buffer, "wb") as wf:
|
||||||
|
wf.setnchannels(channels)
|
||||||
|
wf.setsampwidth(2) # 16bit
|
||||||
|
wf.setframerate(sample_rate)
|
||||||
|
wf.writeframes(pcm_bytes)
|
||||||
|
return wav_buffer.getvalue()
|
||||||
|
|
||||||
def check_vad_update(before_config, new_config):
|
def check_vad_update(before_config, new_config):
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
import yaml
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
import portalocker
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
|
||||||
|
class FileLock:
|
||||||
|
def __init__(self, file, timeout=5):
|
||||||
|
self.file = file
|
||||||
|
self.timeout = timeout
|
||||||
|
self.start_time = None
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.start_time = time.time()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
portalocker.lock(self.file, portalocker.LOCK_EX | portalocker.LOCK_NB)
|
||||||
|
return self.file
|
||||||
|
except portalocker.LockException:
|
||||||
|
if time.time() - self.start_time > self.timeout:
|
||||||
|
raise TimeoutError("获取文件锁超时")
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
portalocker.unlock(self.file)
|
||||||
|
|
||||||
|
|
||||||
|
class WakeupWordsConfig:
|
||||||
|
def __init__(self):
|
||||||
|
self.config_file = "data/.wakeup_words.yaml"
|
||||||
|
self.assets_dir = "config/assets/wakeup_words"
|
||||||
|
self._ensure_directories()
|
||||||
|
self._config_cache = None
|
||||||
|
self._last_load_time = 0
|
||||||
|
self._cache_ttl = 1 # 缓存有效期(秒)
|
||||||
|
self._lock_timeout = 5 # 文件锁超时时间(秒)
|
||||||
|
|
||||||
|
def _ensure_directories(self):
|
||||||
|
"""确保必要的目录存在"""
|
||||||
|
os.makedirs(os.path.dirname(self.config_file), exist_ok=True)
|
||||||
|
os.makedirs(self.assets_dir, exist_ok=True)
|
||||||
|
|
||||||
|
def _load_config(self) -> Dict:
|
||||||
|
"""加载配置文件,使用缓存机制"""
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
# 如果缓存有效,直接返回缓存
|
||||||
|
if (
|
||||||
|
self._config_cache is not None
|
||||||
|
and current_time - self._last_load_time < self._cache_ttl
|
||||||
|
):
|
||||||
|
return self._config_cache
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(self.config_file, "a+") as f:
|
||||||
|
with FileLock(f, timeout=self._lock_timeout):
|
||||||
|
f.seek(0)
|
||||||
|
content = f.read()
|
||||||
|
config = yaml.safe_load(content) if content else {}
|
||||||
|
self._config_cache = config
|
||||||
|
self._last_load_time = current_time
|
||||||
|
return config
|
||||||
|
except (TimeoutError, IOError) as e:
|
||||||
|
print(f"加载配置文件失败: {e}")
|
||||||
|
return {}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"加载配置文件时发生未知错误: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _save_config(self, config: Dict):
|
||||||
|
"""保存配置到文件,使用文件锁保护"""
|
||||||
|
try:
|
||||||
|
with open(self.config_file, "w") as f:
|
||||||
|
with FileLock(f, timeout=self._lock_timeout):
|
||||||
|
yaml.dump(config, f, allow_unicode=True)
|
||||||
|
self._config_cache = config
|
||||||
|
self._last_load_time = time.time()
|
||||||
|
except (TimeoutError, IOError) as e:
|
||||||
|
print(f"保存配置文件失败: {e}")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
print(f"保存配置文件时发生未知错误: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_wakeup_response(self, voice: str) -> Dict:
|
||||||
|
voice = hashlib.md5(voice.encode()).hexdigest()
|
||||||
|
"""获取唤醒词回复配置"""
|
||||||
|
config = self._load_config()
|
||||||
|
|
||||||
|
if not config or voice not in config:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 检查文件大小
|
||||||
|
file_path = config[voice]["file_path"]
|
||||||
|
if not os.path.exists(file_path) or os.stat(file_path).st_size < (15 * 1024):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return config[voice]
|
||||||
|
|
||||||
|
def update_wakeup_response(self, voice: str, file_path: str, text: str):
|
||||||
|
"""更新唤醒词回复配置"""
|
||||||
|
try:
|
||||||
|
# 过滤表情符号
|
||||||
|
filtered_text = re.sub(r'[\U0001F600-\U0001F64F\U0001F900-\U0001F9FF]', '', text)
|
||||||
|
|
||||||
|
config = self._load_config()
|
||||||
|
voice_hash = hashlib.md5(voice.encode()).hexdigest()
|
||||||
|
config[voice_hash] = {
|
||||||
|
"voice": voice,
|
||||||
|
"file_path": file_path,
|
||||||
|
"time": time.time(),
|
||||||
|
"text": filtered_text,
|
||||||
|
}
|
||||||
|
self._save_config(config)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"更新唤醒词回复配置失败: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def generate_file_path(self, voice: str) -> str:
|
||||||
|
"""生成音频文件路径,使用voice的哈希值作为文件名"""
|
||||||
|
try:
|
||||||
|
# 生成voice的哈希值
|
||||||
|
voice_hash = hashlib.md5(voice.encode()).hexdigest()
|
||||||
|
file_path = os.path.join(self.assets_dir, f"{voice_hash}.wav")
|
||||||
|
|
||||||
|
# 如果文件已存在,先删除
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
try:
|
||||||
|
os.remove(file_path)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"删除已存在的音频文件失败: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
return file_path
|
||||||
|
except Exception as e:
|
||||||
|
print(f"生成音频文件路径失败: {e}")
|
||||||
|
raise
|
||||||
@@ -2,35 +2,56 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from typing import Dict
|
import concurrent.futures
|
||||||
|
from typing import Dict, Optional
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from tabulate import tabulate
|
from tabulate import tabulate
|
||||||
from core.utils.asr import create_instance as create_stt_instance
|
from core.utils.asr import create_instance as create_stt_instance
|
||||||
from config.settings import load_config
|
|
||||||
|
|
||||||
# 设置全局日志级别为WARNING,抑制INFO级别日志
|
# 设置全局日志级别为WARNING,抑制INFO级别日志
|
||||||
logging.basicConfig(level=logging.WARNING)
|
logging.basicConfig(level=logging.WARNING)
|
||||||
|
|
||||||
description = "语音识别模型性能测试"
|
description = "语音识别模型性能测试"
|
||||||
|
|
||||||
|
|
||||||
class ASRPerformanceTester:
|
class ASRPerformanceTester:
|
||||||
def __init__(self):
|
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.test_wav_list = self._load_test_wav_files()
|
||||||
self.results = {"stt": {}}
|
self.results = {"stt": {}}
|
||||||
|
|
||||||
# 调试日志
|
# 调试日志
|
||||||
print(f"[DEBUG] 加载的ASR配置: {self.config.get('ASR', {})}")
|
print(f"[DEBUG] 加载的ASR配置: {self.config.get('ASR', {})}")
|
||||||
print(f"[DEBUG] 音频文件数量: {len(self.test_wav_list)}")
|
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:
|
def _load_test_wav_files(self) -> list:
|
||||||
"""加载测试用的音频文件(添加路径调试)"""
|
"""加载测试用的音频文件(添加路径调试)"""
|
||||||
wav_root = os.path.join(os.getcwd(), "config", "assets")
|
wav_root = os.path.join(os.getcwd(), "config", "assets")
|
||||||
print(f"[DEBUG] 音频文件目录: {wav_root}")
|
print(f"[DEBUG] 音频文件目录: {wav_root}")
|
||||||
test_wav_list = []
|
test_wav_list = []
|
||||||
|
|
||||||
if os.path.exists(wav_root):
|
if os.path.exists(wav_root):
|
||||||
file_list = os.listdir(wav_root)
|
file_list = os.listdir(wav_root)
|
||||||
print(f"[DEBUG] 找到音频文件: {file_list}")
|
print(f"[DEBUG] 找到音频文件: {file_list}")
|
||||||
@@ -43,18 +64,46 @@ class ASRPerformanceTester:
|
|||||||
print(f" 目录不存在: {wav_root}")
|
print(f" 目录不存在: {wav_root}")
|
||||||
return test_wav_list
|
return test_wav_list
|
||||||
|
|
||||||
async def _test_stt(self, stt_name: str, config: Dict) -> Dict:
|
async def _test_single_audio(self, stt_name: str, stt, audio_data: bytes) -> Optional[float]:
|
||||||
"""异步测试单个STT性能(跳过无效配置)"""
|
"""测试单个音频文件的性能"""
|
||||||
try:
|
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"]
|
token_fields = ["access_token", "api_key", "token"]
|
||||||
# 忽略值为 "none" 的情况(需根据实际需求调整)
|
|
||||||
if any(
|
if any(
|
||||||
field in config
|
field in config
|
||||||
and str(config[field]).lower() in ["你的", "placeholder"]
|
and str(config[field]).lower() in ["你的", "placeholder", "none", "null", ""]
|
||||||
for field in token_fields
|
for field in token_fields
|
||||||
):
|
):
|
||||||
print(f" STT {stt_name} 未配置access_token/api_key,已跳过")
|
print(f" STT {stt_name} 未配置有效access_token/api_key,已跳过")
|
||||||
return {"name": stt_name, "type": "stt", "errors": 1}
|
return {
|
||||||
|
"name": stt_name,
|
||||||
|
"type": "stt",
|
||||||
|
"errors": 1,
|
||||||
|
"error_type": "配置错误"
|
||||||
|
}
|
||||||
|
|
||||||
module_type = config.get("type", stt_name)
|
module_type = config.get("type", stt_name)
|
||||||
stt = create_stt_instance(module_type, config, delete_audio_file=True)
|
stt = create_stt_instance(module_type, config, delete_audio_file=True)
|
||||||
@@ -62,56 +111,203 @@ class ASRPerformanceTester:
|
|||||||
|
|
||||||
print(f" 测试 STT: {stt_name}")
|
print(f" 测试 STT: {stt_name}")
|
||||||
|
|
||||||
# 测试第一个音频文件
|
# 使用线程池和超时控制
|
||||||
text, _ = await stt.speech_to_text(
|
loop = asyncio.get_event_loop()
|
||||||
[self.test_wav_list[0]], "1", stt.audio_format
|
|
||||||
)
|
# 测试第一个音频文件作为连通性检查
|
||||||
if text is None:
|
try:
|
||||||
print(f" {stt_name} 连接失败")
|
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||||
return {"name": stt_name, "type": "stt", "errors": 1}
|
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
|
total_time = 0
|
||||||
|
valid_tests = 0
|
||||||
test_count = len(self.test_wav_list)
|
test_count = len(self.test_wav_list)
|
||||||
for i, sentence in enumerate(self.test_wav_list, 1):
|
|
||||||
start = time.time()
|
for i, audio_data in enumerate(self.test_wav_list, 1):
|
||||||
text, _ = await stt.speech_to_text([sentence], "1", stt.audio_format)
|
try:
|
||||||
duration = time.time() - start
|
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||||
total_time += duration
|
future = executor.submit(
|
||||||
print(f" {stt_name} [{i}/{test_count}] 耗时: {duration:.2f}s")
|
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 {
|
return {
|
||||||
"name": stt_name,
|
"name": stt_name,
|
||||||
"type": "stt",
|
"type": "stt",
|
||||||
"avg_time": total_time / test_count,
|
"avg_time": avg_time,
|
||||||
|
"success_rate": f"{valid_tests}/{test_count}",
|
||||||
"errors": 0,
|
"errors": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
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)}")
|
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):
|
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():
|
for name, data in self.results["stt"].items():
|
||||||
if data["errors"] == 0:
|
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")
|
valid_results.sort(key=lambda x: x["sort_key"])
|
||||||
print(
|
|
||||||
tabulate(
|
# 将排序后的有效结果转换为表格数据
|
||||||
stt_table,
|
for result in valid_results:
|
||||||
headers=["模型名称", "平均耗时"],
|
table_data.append([
|
||||||
tablefmt="github",
|
result["name"],
|
||||||
colalign=("left", "right"),
|
result["avg_time"],
|
||||||
)
|
result["success_rate"],
|
||||||
)
|
result["status"],
|
||||||
else:
|
])
|
||||||
print("\n 没有可用的ASR模块进行测试。")
|
|
||||||
|
# 将错误结果添加到表格数据末尾
|
||||||
|
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):
|
async def run(self):
|
||||||
"""执行全量异步测试"""
|
"""执行全量异步测试"""
|
||||||
print("开始筛选可用ASR模块...")
|
print("开始筛选可用ASR模块...")
|
||||||
if not self.config.get("ASR"):
|
if not self.config.get("ASR"):
|
||||||
print("配置中未找到 ASR 模块")
|
print("配置中未找到 ASR 模块")
|
||||||
@@ -119,24 +315,33 @@ class ASRPerformanceTester:
|
|||||||
|
|
||||||
all_tasks = []
|
all_tasks = []
|
||||||
for stt_name, config in self.config["ASR"].items():
|
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:
|
if not all_tasks:
|
||||||
print("没有可用的ASR模块进行测试。")
|
print("没有可用的ASR模块进行测试。")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
print(f"\n找到 {len(all_tasks)} 个可用ASR模块")
|
||||||
print("\n开始并发测试所有ASR模块...")
|
print("\n开始并发测试所有ASR模块...")
|
||||||
all_results = await asyncio.gather(*all_tasks, return_exceptions=True)
|
all_results = await asyncio.gather(*all_tasks, return_exceptions=True)
|
||||||
|
|
||||||
# 处理结果
|
# 处理结果
|
||||||
for result in all_results:
|
for result in all_results:
|
||||||
if isinstance(result, dict) and result.get("type") == "stt":
|
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()
|
self._print_results()
|
||||||
|
|
||||||
|
|
||||||
@@ -146,4 +351,4 @@ async def main():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -86,22 +86,67 @@ class TTSPerformanceTester:
|
|||||||
print("没有有效的TTS测试结果")
|
print("没有有效的TTS测试结果")
|
||||||
return
|
return
|
||||||
|
|
||||||
table = []
|
headers = ["TTS模块", "平均耗时(秒)", "测试句子数", "状态"]
|
||||||
|
table_data = []
|
||||||
|
|
||||||
|
# 收集所有数据并分类
|
||||||
|
valid_results = []
|
||||||
|
error_results = []
|
||||||
|
|
||||||
for name, data in self.results.items():
|
for name, data in self.results.items():
|
||||||
if data["errors"] == 0:
|
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("\nTTS性能测试结果:")
|
||||||
print(
|
print(
|
||||||
tabulate(
|
tabulate(
|
||||||
table,
|
table_data,
|
||||||
headers=["TTS模块", "平均耗时", "测试句子数"],
|
headers=headers,
|
||||||
tablefmt="github",
|
tablefmt="grid",
|
||||||
colalign=("left", "right", "right"),
|
colalign=("left", "right", "right", "left"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
print("\n测试说明:")
|
||||||
|
print("- 超时控制: 单个请求最大等待时间为10秒")
|
||||||
|
print("- 错误处理: 无法连接和超时的列为网络错误")
|
||||||
|
print("- 排序规则: 按平均耗时从快到慢排序")
|
||||||
|
|
||||||
async def run(self):
|
async def run(self):
|
||||||
"""执行测试"""
|
"""执行测试"""
|
||||||
@@ -119,10 +164,9 @@ class TTSPerformanceTester:
|
|||||||
# 并发执行测试
|
# 并发执行测试
|
||||||
results = await asyncio.gather(*tasks)
|
results = await asyncio.gather(*tasks)
|
||||||
|
|
||||||
# 保存有效结果
|
# 保存所有结果,包括错误
|
||||||
for result in results:
|
for result in results:
|
||||||
if result["errors"] == 0:
|
self.results[result["name"]] = result
|
||||||
self.results[result["name"]] = result
|
|
||||||
|
|
||||||
# 打印结果
|
# 打印结果
|
||||||
self._print_results()
|
self._print_results()
|
||||||
|
|||||||
@@ -125,7 +125,8 @@ def fetch_news_from_api(conn, source="thepaper"):
|
|||||||
]["get_news_from_newsnow"].get("url"):
|
]["get_news_from_newsnow"].get("url"):
|
||||||
api_url = conn.config["plugins"]["get_news_from_newsnow"]["url"] + source
|
api_url = conn.config["plugins"]["get_news_from_newsnow"]["url"] + source
|
||||||
|
|
||||||
response = requests.get(api_url, timeout=10)
|
headers = {"User-Agent": "Mozilla/5.0"}
|
||||||
|
response = requests.get(api_url, headers=headers, timeout=10)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@@ -144,7 +145,8 @@ def fetch_news_from_api(conn, source="thepaper"):
|
|||||||
def fetch_news_detail(url):
|
def fetch_news_detail(url):
|
||||||
"""获取新闻详情页内容并使用MarkItDown清理HTML"""
|
"""获取新闻详情页内容并使用MarkItDown清理HTML"""
|
||||||
try:
|
try:
|
||||||
response = requests.get(url, timeout=10)
|
headers = {"User-Agent": "Mozilla/5.0"}
|
||||||
|
response = requests.get(url, headers=headers, timeout=10)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
# 使用MarkItDown清理HTML内容
|
# 使用MarkItDown清理HTML内容
|
||||||
|
|||||||
@@ -110,6 +110,11 @@ WEATHER_CODE_MAP = {
|
|||||||
def fetch_city_info(location, api_key, api_host):
|
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"
|
url = f"https://{api_host}/geo/v2/city/lookup?key={api_key}&location={location}&lang=zh"
|
||||||
response = requests.get(url, headers=HEADERS).json()
|
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
|
return response.get("location", [])[0] if response.get("location") else None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,12 +29,7 @@ hass_get_state_function_desc = {
|
|||||||
@register_function("hass_get_state", hass_get_state_function_desc, ToolType.SYSTEM_CTL)
|
@register_function("hass_get_state", hass_get_state_function_desc, ToolType.SYSTEM_CTL)
|
||||||
def hass_get_state(conn, entity_id=""):
|
def hass_get_state(conn, entity_id=""):
|
||||||
try:
|
try:
|
||||||
|
ha_response = handle_hass_get_state(conn, entity_id)
|
||||||
future = asyncio.run_coroutine_threadsafe(
|
|
||||||
handle_hass_get_state(conn, entity_id), conn.loop
|
|
||||||
)
|
|
||||||
# 添加10秒超时
|
|
||||||
ha_response = future.result(timeout=10)
|
|
||||||
return ActionResponse(Action.REQLLM, ha_response, None)
|
return ActionResponse(Action.REQLLM, ha_response, None)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.bind(tag=TAG).error("获取Home Assistant状态超时")
|
logger.bind(tag=TAG).error("获取Home Assistant状态超时")
|
||||||
@@ -45,13 +40,13 @@ def hass_get_state(conn, entity_id=""):
|
|||||||
return ActionResponse(Action.ERROR, error_msg, None)
|
return ActionResponse(Action.ERROR, error_msg, None)
|
||||||
|
|
||||||
|
|
||||||
async def handle_hass_get_state(conn, entity_id):
|
def handle_hass_get_state(conn, entity_id):
|
||||||
ha_config = initialize_hass_handler(conn)
|
ha_config = initialize_hass_handler(conn)
|
||||||
api_key = ha_config.get("api_key")
|
api_key = ha_config.get("api_key")
|
||||||
base_url = ha_config.get("base_url")
|
base_url = ha_config.get("base_url")
|
||||||
url = f"{base_url}/api/states/{entity_id}"
|
url = f"{base_url}/api/states/{entity_id}"
|
||||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||||
response = requests.get(url, headers=headers)
|
response = requests.get(url, headers=headers, timeout=5)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
responsetext = "设备状态:" + response.json()["state"] + " "
|
responsetext = "设备状态:" + response.json()["state"] + " "
|
||||||
logger.bind(tag=TAG).info(f"api返回内容: {response.json()}")
|
logger.bind(tag=TAG).info(f"api返回内容: {response.json()}")
|
||||||
|
|||||||
@@ -54,11 +54,7 @@ def hass_set_state(conn, entity_id="", state=None):
|
|||||||
if state is None:
|
if state is None:
|
||||||
state = {}
|
state = {}
|
||||||
try:
|
try:
|
||||||
future = asyncio.run_coroutine_threadsafe(
|
ha_response = handle_hass_set_state(conn, entity_id, state)
|
||||||
handle_hass_set_state(conn, entity_id, state), conn.loop
|
|
||||||
)
|
|
||||||
# 添加10秒超时
|
|
||||||
ha_response = future.result(timeout=10)
|
|
||||||
return ActionResponse(Action.REQLLM, ha_response, None)
|
return ActionResponse(Action.REQLLM, ha_response, None)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.bind(tag=TAG).error("设置Home Assistant状态超时")
|
logger.bind(tag=TAG).error("设置Home Assistant状态超时")
|
||||||
@@ -69,7 +65,7 @@ def hass_set_state(conn, entity_id="", state=None):
|
|||||||
return ActionResponse(Action.ERROR, error_msg, None)
|
return ActionResponse(Action.ERROR, error_msg, None)
|
||||||
|
|
||||||
|
|
||||||
async def handle_hass_set_state(conn, entity_id, state):
|
def handle_hass_set_state(conn, entity_id, state):
|
||||||
ha_config = initialize_hass_handler(conn)
|
ha_config = initialize_hass_handler(conn)
|
||||||
api_key = ha_config.get("api_key")
|
api_key = ha_config.get("api_key")
|
||||||
base_url = ha_config.get("base_url")
|
base_url = ha_config.get("base_url")
|
||||||
@@ -169,7 +165,7 @@ async def handle_hass_set_state(conn, entity_id, state):
|
|||||||
data = {"entity_id": entity_id, arg: value}
|
data = {"entity_id": entity_id, arg: value}
|
||||||
url = f"{base_url}/api/services/{domain}/{action}"
|
url = f"{base_url}/api/services/{domain}/{action}"
|
||||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||||
response = requests.post(url, headers=headers, json=data)
|
response = requests.post(url, headers=headers, json=data, timeout=5) # 设置5秒超时
|
||||||
logger.bind(tag=TAG).info(
|
logger.bind(tag=TAG).info(
|
||||||
f"设置状态:{description},url:{url},return_code:{response.status_code}"
|
f"设置状态:{description},url:{url},return_code:{response.status_code}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -181,6 +181,7 @@
|
|||||||
import { checkOpusLoaded, initOpusEncoder } from './js/opus.js';
|
import { checkOpusLoaded, initOpusEncoder } from './js/opus.js';
|
||||||
import { addMessage } from './js/document.js'
|
import { addMessage } from './js/document.js'
|
||||||
import BlockingQueue from './js/utils/BlockingQueue.js'
|
import BlockingQueue from './js/utils/BlockingQueue.js'
|
||||||
|
import { createStreamingContext } from './js/StreamingContext.js'
|
||||||
// 需要加载的脚本列表 - 移除Opus依赖
|
// 需要加载的脚本列表 - 移除Opus依赖
|
||||||
const scriptFiles = [];
|
const scriptFiles = [];
|
||||||
|
|
||||||
@@ -230,6 +231,16 @@
|
|||||||
const conversationDiv = document.getElementById('conversation');
|
const conversationDiv = document.getElementById('conversation');
|
||||||
const logContainer = document.getElementById('logContainer');
|
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() {
|
function initVisualizer() {
|
||||||
@@ -306,12 +317,7 @@
|
|||||||
// 确保Opus解码器已初始化
|
// 确保Opus解码器已初始化
|
||||||
try {
|
try {
|
||||||
// 确保音频上下文存在
|
// 确保音频上下文存在
|
||||||
if (!audioContext) {
|
audioContext = getAudioContextInstance();
|
||||||
audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
|
||||||
sampleRate: SAMPLE_RATE
|
|
||||||
});
|
|
||||||
log('创建音频上下文,采样率: ' + SAMPLE_RATE + 'Hz', 'debug');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确保解码器已初始化
|
// 确保解码器已初始化
|
||||||
if (!opusDecoder) {
|
if (!opusDecoder) {
|
||||||
@@ -331,125 +337,7 @@
|
|||||||
|
|
||||||
// 创建流式播放上下文
|
// 创建流式播放上下文
|
||||||
if (!streamingContext) {
|
if (!streamingContext) {
|
||||||
streamingContext = {
|
streamingContext = createStreamingContext(opusDecoder, audioContext, SAMPLE_RATE, CHANNELS, MIN_AUDIO_DURATION);
|
||||||
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.decodeOpusFrames();
|
streamingContext.decodeOpusFrames();
|
||||||
@@ -462,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解码器 - 确保完全初始化完成后才返回
|
// 初始化Opus解码器 - 确保完全初始化完成后才返回
|
||||||
async function initOpusDecoder() {
|
async function initOpusDecoder() {
|
||||||
@@ -617,10 +497,7 @@
|
|||||||
log('已获取麦克风访问权限', 'success');
|
log('已获取麦克风访问权限', 'success');
|
||||||
|
|
||||||
// 创建音频上下文
|
// 创建音频上下文
|
||||||
audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
audioContext = getAudioContextInstance();
|
||||||
sampleRate: 16000, // 确保采样率与服务器期望的一致
|
|
||||||
latencyHint: 'interactive'
|
|
||||||
});
|
|
||||||
const source = audioContext.createMediaStreamSource(stream);
|
const source = audioContext.createMediaStreamSource(stream);
|
||||||
|
|
||||||
// 获取实际音频轨道设置
|
// 获取实际音频轨道设置
|
||||||
@@ -1416,12 +1293,7 @@
|
|||||||
|
|
||||||
// 创建音频处理器
|
// 创建音频处理器
|
||||||
async function createAudioProcessor() {
|
async function createAudioProcessor() {
|
||||||
if (!audioContext) {
|
audioContext = getAudioContextInstance();
|
||||||
audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
|
||||||
sampleRate: 16000,
|
|
||||||
latencyHint: 'interactive'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 检查是否支持AudioWorklet
|
// 检查是否支持AudioWorklet
|
||||||
@@ -1616,12 +1488,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 创建音频上下文和分析器
|
// 创建音频上下文和分析器
|
||||||
if (!audioContext) {
|
audioContext = getAudioContextInstance();
|
||||||
audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
|
||||||
sampleRate: 16000,
|
|
||||||
latencyHint: 'interactive'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建音频处理器
|
// 创建音频处理器
|
||||||
const processorResult = await createAudioProcessor();
|
const processorResult = await createAudioProcessor();
|
||||||
|
|||||||
Reference in New Issue
Block a user