feat(xiaozhi): 新增小智 AI 一键运行 Docker 镜像

This commit is contained in:
Del Wang
2025-05-19 00:16:49 +08:00
parent 59c3280938
commit 58acbc29f4
39 changed files with 1729 additions and 1152 deletions
+17 -6
View File
@@ -1,7 +1,12 @@
# 排除全部文件
**/*
# 只保留构建相关的文件
# Rust
!packages/client-rust/src
!packages/client-rust/Cargo.toml
!packages/client-rust/Cargo.lock
# MiGPT
!examples/migpt/src
!examples/migpt/migpt
!examples/migpt/config.ts
@@ -12,8 +17,14 @@
!examples/migpt/tsconfig.json
examples/migpt/migpt/open-xiaoai.node
!packages/client-rust/src
!packages/client-rust/Cargo.toml
!packages/client-rust/Cargo.lock
# XiaoZhi
!examples/xiaozhi/src
!examples/xiaozhi/xiaozhi
!examples/xiaozhi/Cargo.toml
!examples/xiaozhi/Cargo.lock
!examples/xiaozhi/config.py
!examples/xiaozhi/main.py
!examples/xiaozhi/uv.lock
!examples/xiaozhi/.python-version
!examples/xiaozhi/requirements.txt
!examples/xiaozhi/pyproject.toml
+1
View File
@@ -39,6 +39,7 @@ yarn-error.log*
.venv
.mypy_cache
__pycache__
*.egg-info
# Misc
.DS_Store
+1 -1
View File
@@ -19,7 +19,7 @@
```bash
# 安装 Python 依赖
uv sync
uv sync --locked
# 编译运行
uv run main.py
+4 -2
View File
@@ -124,9 +124,11 @@ curl -sSfL https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-kws
### Q:能不能设置英文/方言作为唤醒词?比如:Siri
默认使用的小型语音识别模型只支持中文(普通话)作为唤醒词。
默认使用的小型语音识别模型只支持中文(普通话)作为唤醒词。
如果你需要多语言唤醒词比如英文、日语、韩语或方言等,可以在 server 端运行更大规模、更先进的 AI 模型,来进行唤醒词识别。推荐使用 [FunASR](https://github.com/modelscope/FunASR) 和 [Sherpa-ONNX](https://github.com/k2-fsa/sherpa-onnx),可以参考 [xiaozhi-esp32-server](https://github.com/xinnan-tech/xiaozhi-esp32-server) 项目
如果你想要使用英文唤醒词比如:siri),或者更灵敏的唤醒词识别效果,可以参考这个 [Server 端演示](../xiaozhi/README.md)
如果你需要更多语言的唤醒词,比如日语、韩语或方言等,可以在 server 端运行更大规模、更先进的 AI 模型,来进行唤醒词识别。推荐使用 [FunASR](https://github.com/modelscope/FunASR) 和 [Sherpa-ONNX](https://github.com/k2-fsa/sherpa-onnx),可以参考 [xiaozhi-esp32-server](https://github.com/xinnan-tech/xiaozhi-esp32-server) 项目。
### Q:是否支持说话人(声纹)识别?
+1 -1
View File
@@ -104,7 +104,7 @@ class SpeakerManager implements ISpeaker {
silent: boolean;
}
) {
const { silent = false } = options ?? {};
const { silent = true } = options ?? {};
const command = awake
? silent
? `ubus call pnshelper event_notify '{"src":1,"event":0}'`
-1
View File
@@ -17,7 +17,6 @@
"**/node_modules": true,
"**/__pycache__": true,
"*.egg-info": true,
".venv": true,
".mypy_cache": true
}
}
+57
View File
@@ -0,0 +1,57 @@
FROM python:3.12-slim AS builder
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
# 更新源
RUN apt-get update && apt-get install -y --no-install-recommends \
curl build-essential libopus-dev \
&& rm -rf /var/lib/apt/lists/*
# 安装 Rust
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
# 安装 uv
COPY --from=ghcr.io/astral-sh/uv:0.7 /uv /uvx /bin/
# 设置环境变量
ENV BASH_ENV=/root/.bash_env
RUN touch "$BASH_ENV"
RUN echo '. "$BASH_ENV"' >> "$HOME/.bashrc"
RUN echo '[ -s "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"' >> "$BASH_ENV"
# 设置工作目录
WORKDIR /app
# 复制项目文件
COPY examples/xiaozhi .
COPY packages/client-rust ./client-rust
# 构建
RUN sed -i 's/\.\.\/\.\.\/packages\///g' Cargo.toml \
&& cargo build --release
# 安装依赖
RUN --mount=type=cache,target=/root/.cache/uv \
uv remove pyaudio && uv sync --locked --no-install-project --no-editable
# 构建 wheel 并安装
RUN uv run maturin build --release && uv remove maturin
FROM python:3.12-slim
WORKDIR /app
# 更新源
RUN apt-get update && apt-get install -y --no-install-recommends \
libopus-dev \
&& rm -rf /var/lib/apt/lists/*
ENV CLI=true
COPY --from=builder /app/.venv /app/.venv
COPY examples/xiaozhi/main.py .
COPY examples/xiaozhi/xiaozhi ./xiaozhi
# 先初始化关键词模型,然后启动主程序
CMD ["/bin/bash", "-c", "source /app/.venv/bin/activate && python xiaozhi/services/audio/kws/keywords.py && python main.py"]
+125 -22
View File
@@ -2,26 +2,66 @@
[Open-XiaoAI](https://github.com/idootop/open-xiaoai) 的 Python 版 Server 端,用来演示小爱音箱接入[小智 AI](https://github.com/78/xiaozhi-esp32)。
> [!NOTE]
> 该演示使用的 Python 版小智 AI 客户端基于 [py-xiaozhi](https://github.com/Huang-junsen/py-xiaozhi) 项目,特此鸣谢。
- 小爱音箱接入小智 AI
- 自定义唤醒词(中英文)和提示语
- 支持连续对话和中途打断
- 支持自定义消息处理,方便个人定制
## 环境准备
## 快速开始
> [!NOTE]
> 继续下面的操作之前,你需要先在小爱音箱上启动运行 Rust 补丁程序 [👉 教程](../../packages/client-rust/README.md)
首先,克隆仓库代码到本地。
```shell
# 克隆代码
git clone https://github.com/idootop/open-xiaoai.git
# 进入当前项目根目录
cd examples/xiaozhi
```
然后把 `config.py` 文件里的配置修改成你自己的。
```typescript
APP_CONFIG = {
"wakeup": {
#
"keywords": [
"豆包豆包",
"你好小智",
"hi siri",
],
},
"xiaozhi": {
"OTA_URL": "https://api.tenclass.net/xiaozhi/ota/",
"WEBSOCKET_URL": "wss://api.tenclass.net/xiaozhi/v1/",
},
}
```
### Docker 运行
[![Docker Image Version](https://img.shields.io/docker/v/idootop/open-xiaoai-xiaozhi?color=%23086DCD&label=docker%20image)](https://hub.docker.com/r/idootop/open-xiaoai-xiaozhi)
推荐使用以下命令,直接 Docker 一键运行。
```shell
docker run -it --rm -p 4399:4399 -v $(pwd)/config.py:/app/config.py idootop/open-xiaoai-xiaozhi:latest
```
### 编译运行
为了能够正常编译运行该项目,你需要安装以下依赖环境/工具:
- uvhttps://github.com/astral-sh/uv
- Rust: https://www.rust-lang.org/learn/get-started
> 如果你是 macOS 系统,还需要手动安装 [Opus](https://opus-codec.org/) 👉 `brew install opus`
## 编译运行
> [!NOTE]
> 请先确认你已经将小爱音箱刷机成功,并安装运行了 Rust 补丁程序 [👉 教程](../../packages/client-rust/README.md),否则该 Python Server 端启动后收不到音频输入,将无法正常工作。
- [Opus](https://opus-codec.org/): 自行询问 AI 如何安装动态链接库,或参考[这篇文章](https://github.com/huangjunsen0406/py-xiaozhi/blob/3bfd2887244c510a13912c1d63263ae564a941e9/documents/docs/guide/01_%E7%B3%BB%E7%BB%9F%E4%BE%9D%E8%B5%96%E5%AE%89%E8%A3%85.md#2-opus-%E9%9F%B3%E9%A2%91%E7%BC%96%E8%A7%A3%E7%A0%81%E5%99%A8)
```bash
# 安装 Python 依赖
uv sync
uv sync --locked
# 编译运行
uv run main.py
@@ -35,25 +75,88 @@ uv run main.py --mode xiaozhi
该模式下使用电脑的麦克风和扬声器作为音频输入输出设备,无需连接小爱音箱。
## 配置文件
> [!NOTE]
> 本项目只是一个简单的演示程序,抛砖引玉。诸如一些音频压缩、加密传输、多账号管理等功能并未提供,请自行评估用途和使用风险。
## 常见问题
### Q:回答太长了,如何打断小智 AI 的回答?
直接召唤“小爱同学”,即可打断小智 AI 的回答 ;)
### Q:第一次运行提示我输入验证码绑定设备,如何操作?
第一次启动对话时,会有语音提示使用验证码绑定设备。请打开你的小智 AI [管理后台](https://xiaozhi.me/),然后根据提示创建 Agent 绑定设备即可。验证码消息会在终端打印,或者打开你的 `config.py` 文件查看。
```py
APP_CONFIG = {
"xiaozhi": {
"VERIFICATION_INFO": "首次登录时,验证码会在这里更新",
},
# ... 其他配置
}
```
PS:绑定设备成功后,可能需要重启应用才会生效。
### Q:怎样使用自己部署的 [xiaozhi-esp32-server](https://github.com/xinnan-tech/xiaozhi-esp32-server) 服务?
如果你想使用自己部署的 [xiaozhi-esp32-server](https://github.com/xinnan-tech/xiaozhi-esp32-server),请更新 `config.py` 文件里的接口地址,然后重启应用。
```py
XIAOZHI_CONFIG = {
"OTA_URL": "https://2662r3426b.vicp.fun/xiaozhi/ota/",
"WEBSOCKET_URL": "wss://2662r3426b.vicp.fun/xiaozhi/v1/",
"WEBSOCKET_ACCESS_TOKEN": "xxxxxxxxxxxxx",
APP_CONFIG = {
"xiaozhi": {
"OTA_URL": "https://2662r3426b.vicp.fun/xiaozhi/ota/",
"WEBSOCKET_URL": "wss://2662r3426b.vicp.fun/xiaozhi/v1/",
},
# ... 其他配置
}
```
## 注意事项
### Q:有时候话还没说完 AI 就开始回答了,如何优化?
首次启动会自动打开小智 AI [管理后台](https://xiaozhi.me/),然后提供一个验证码用来绑定设备
你可以调大 `config.py` 配置文件里的 `min_silence_duration` 参数,然后重启应用 / Docker 试试看
请按照提示注册你的小智 AI 账号,然后创建 Agent 绑定设备即可开始体验。
```py
APP_CONFIG = {
"vad": {
# 最小静默时长(ms
"min_silence_duration": 1000,
},
# ... 其他配置
}
```
注意:绑定设备成功后,需要重新运行本应用才会生效。
### Q:对话的时候,文字识别不是很准?
> [!NOTE]
> 本项目只是一个简单的演示程序,抛砖引玉。如果你想要更多的功能,比如唤醒词识别、语音转文字、连续对话等,可以参考开源的 [xiaozhi-esp32-server](https://github.com/xinnan-tech/xiaozhi-esp32-server) 项目,借助 Python 丰富的 AI 生态自行实现。
文字识别结果取决于你的小智 AI 服务器端的语音识别方案,与本项目无关。
不过需要注意的是,在唤醒后或连续对话时,由于 VAD 激活阈值的关系,开始的一小段语音可能会被丢弃识别不到,使用时需要多注意。
比如:“你知道我是谁吗” 有可能会被识别为“知道我是谁吗”(丢掉了最前面一个字)
### Q:唤醒词一直没有反应?
由于小爱音箱远场拾音音量较小,有时可能会识别不清,你可以调大 `config.py` 配置文件里的 `boost` 参数,然后重启应用 / Docker 试试看。
```py
APP_CONFIG = {
"vad": {
# 小爱音箱录音音量较小,需要后期放大一下
"boost": 100,
# boost 调大后,语音检测阈值可能也需要一起调大些
"threshold": 0.50,
},
# ... 其他配置
}
```
另外,应用 / Docker 刚刚启动时需要加载模型文件,比较耗时一些,可以等 30s 之后再试试看。
如果是英文唤醒词,可以尝试将最小发音用空格分开,比如:比如:'openai' 👉 'open ai'
PS:如果还是不行,建议更换其他更易识别的唤醒词。
## 鸣谢
该演示使用的 Python 版小智 AI 客户端基于 [py-xiaozhi](https://github.com/Huang-junsen/py-xiaozhi) 项目,特此鸣谢。
-25
View File
@@ -1,25 +0,0 @@
#! /bin/sh
set -e
DOWNLOAD_BASE_URL="https://gitee.com/idootop/artifacts/releases/download"
WORK_DIR="/data/open-xiaoai/scripts"
if [ ! -d "$WORK_DIR" ]; then
mkdir -p "$WORK_DIR"
fi
if [ ! -f "$WORK_DIR/client-boot.sh" ]; then
curl -L -# -o "$WORK_DIR/client-boot.sh" "$DOWNLOAD_BASE_URL/open-xiaoai-client/boot.sh"
fi
if [ ! -f "$WORK_DIR/kws-boot.sh" ]; then
curl -L -# -o "$WORK_DIR/kws-boot.sh" "$DOWNLOAD_BASE_URL/open-xiaoai-kws/boot.sh"
fi
kill -9 `ps|grep "open-xiaoai/kws/monitor"|grep -v grep|awk '{print $1}'` > /dev/null 2>&1 || true
sh "$WORK_DIR/kws-boot.sh" --no-monitor > /dev/null 2>&1 &
sh "$WORK_DIR/client-boot.sh"
+78 -4
View File
@@ -1,5 +1,79 @@
XIAOZHI_CONFIG = {
"OTA_URL": "https://api.tenclass.net/xiaozhi/ota/",
"WEBSOCKET_URL": "wss://api.tenclass.net/xiaozhi/v1/",
"WEBSOCKET_ACCESS_TOKEN": "xxxxxxxxxxxxx",
import asyncio
async def before_wakeup(speaker, text, source):
"""
处理收到的用户消息,并决定是否唤醒小智 AI
- source: 唤醒来源
- 'kws': 关键字唤醒
- 'xiaoai': 小爱同学收到用户指令
"""
if source == "kws" and text == "你好小智":
# 播放唤醒提示语
await speaker.play(text="你好主人,我是小智,请问有什么吩咐?")
# 返回 True 唤醒小智 AI
return True
if source == "kws" and text == "hi siri":
# 播放唤醒提示语
await speaker.play(text="I'm Siri, how can I help you?")
# 唤醒小爱
await speaker.wake_up()
return False
if source == "xiaoai" and text == "召唤小智":
# 打断原来的小爱同学
await speaker.abort_xiaoai()
# 等待 2 秒,让小爱 TTS 恢复可用
await asyncio.sleep(2)
# 播放唤醒提示语(如果你不使用自带的小爱 TTS,可以去掉上面的延时)
await speaker.play(text="小智来了,主人有什么吩咐?")
# 唤醒小智 AI
return True
async def after_wakeup(speaker):
"""
退出唤醒状态
"""
await speaker.play(text="主人再见,拜拜")
APP_CONFIG = {
"wakeup": {
# 自定义唤醒词列表(英文字母要全小写)
"keywords": [
"天猫精灵",
"小度小度",
"豆包豆包",
"你好小智",
"你好小爱",
"hi siri",
"hey siri",
],
# 静音多久后自动退出唤醒(秒)
"timeout": 20,
# 语音识别结果回调
"before_wakeup": before_wakeup,
# 退出唤醒时的提示语(设置为空可关闭)
"after_wakeup": after_wakeup,
},
"vad": {
# 录音音量增强倍数(小爱音箱录音音量较小,需要后期放大一下)
"boost": 10,
# 语音检测阈值(0-1,越小越灵敏)
"threshold": 0.10,
# 最小语音时长(ms
"min_speech_duration": 250,
# 最小静默时长(ms
"min_silence_duration": 500,
},
"xiaozhi": {
"OTA_URL": "https://api.tenclass.net/xiaozhi/ota/",
"WEBSOCKET_URL": "wss://api.tenclass.net/xiaozhi/v1/",
"WEBSOCKET_ACCESS_TOKEN": "(可选)一般用不到这个值",
"DEVICE_ID": "(可选)默认自动生成",
"VERIFICATION_CODE": "首次登陆时,验证码会在这里更新",
},
}
-25
View File
@@ -1,25 +0,0 @@
#! /bin/sh
set -e
DOWNLOAD_BASE_URL="https://gitee.com/idootop/artifacts/releases/download"
WORK_DIR="/data/open-xiaoai/scripts"
if [ ! -d "$WORK_DIR" ]; then
mkdir -p "$WORK_DIR"
fi
if [ ! -f "$WORK_DIR/client.sh" ]; then
curl -L -# -o "$WORK_DIR/client.sh" "$DOWNLOAD_BASE_URL/open-xiaoai-client/init.sh"
fi
if [ ! -f "$WORK_DIR/kws.sh" ]; then
curl -L -# -o "$WORK_DIR/kws.sh" "$DOWNLOAD_BASE_URL/open-xiaoai-kws/init.sh"
fi
kill -9 `ps|grep "open-xiaoai/kws/monitor"|grep -v grep|awk '{print $1}'` > /dev/null 2>&1 || true
sh "$WORK_DIR/kws.sh" --no-monitor > /dev/null 2>&1 &
sh "$WORK_DIR/client.sh"
-8
View File
@@ -1,18 +1,11 @@
import logging
import signal
import sys
from xiaozhi.services.audio.setup import setup_opus
from xiaozhi.utils.logging_config import setup_logging
from xiaozhi.xiaoai import XiaoAI
from xiaozhi.xiaozhi import XiaoZhi
logger = logging.getLogger("Main")
def main():
setup_opus()
logger.info("应用程序已启动,按Ctrl+C退出")
XiaoZhi.instance().run()
return 0
@@ -27,6 +20,5 @@ def setup_graceful_shutdown():
if __name__ == "__main__":
XiaoAI.setup_mode()
setup_logging()
setup_graceful_shutdown()
sys.exit(main())
+5 -3
View File
@@ -2,16 +2,18 @@
name = "open-xiaoai-xiaozhi"
version = "1.0.0"
description = "小爱音箱接入小智 AI"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"asyncio>=3.4.3",
"cryptography>=44.0.2",
"maturin>=1.8.3",
"numpy>=2.2.3",
"opuslib>=3.0.1",
"onnxruntime>=1.22.0",
"opuslib-next>=1.1.4",
"pyaudio>=0.2.14",
"pypinyin>=0.54.0",
"requests>=2.32.3",
"sentencepiece>=0.2.0",
"sherpa-onnx>=1.12.0",
"websockets>=15.0.1",
]
+162 -72
View File
@@ -20,39 +20,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 },
]
[[package]]
name = "cffi"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 },
{ url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 },
{ url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 },
{ url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 },
{ url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 },
{ url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 },
{ url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 },
{ url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 },
{ url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 },
{ url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 },
{ url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 },
{ url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 },
{ url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 },
{ url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 },
{ url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 },
{ url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 },
{ url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 },
{ url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 },
{ url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 },
{ url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 },
{ url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 },
{ url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 },
]
[[package]]
name = "charset-normalizer"
version = "3.4.1"
@@ -89,38 +56,36 @@ wheels = [
]
[[package]]
name = "cryptography"
version = "44.0.2"
name = "coloredlogs"
version = "15.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
{ name = "humanfriendly" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/25/4ce80c78963834b8a9fd1cc1266be5ed8d1840785c0f2e1b73b8d128d505/cryptography-44.0.2.tar.gz", hash = "sha256:c63454aa261a0cf0c5b4718349629793e9e634993538db841165b3df74f37ec0", size = 710807 }
sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/92/ef/83e632cfa801b221570c5f58c0369db6fa6cef7d9ff859feab1aae1a8a0f/cryptography-44.0.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:efcfe97d1b3c79e486554efddeb8f6f53a4cdd4cf6086642784fa31fc384e1d7", size = 6676361 },
{ url = "https://files.pythonhosted.org/packages/30/ec/7ea7c1e4c8fc8329506b46c6c4a52e2f20318425d48e0fe597977c71dbce/cryptography-44.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29ecec49f3ba3f3849362854b7253a9f59799e3763b0c9d0826259a88efa02f1", size = 3952350 },
{ url = "https://files.pythonhosted.org/packages/27/61/72e3afdb3c5ac510330feba4fc1faa0fe62e070592d6ad00c40bb69165e5/cryptography-44.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc821e161ae88bfe8088d11bb39caf2916562e0a2dc7b6d56714a48b784ef0bb", size = 4166572 },
{ url = "https://files.pythonhosted.org/packages/26/e4/ba680f0b35ed4a07d87f9e98f3ebccb05091f3bf6b5a478b943253b3bbd5/cryptography-44.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3c00b6b757b32ce0f62c574b78b939afab9eecaf597c4d624caca4f9e71e7843", size = 3958124 },
{ url = "https://files.pythonhosted.org/packages/9c/e8/44ae3e68c8b6d1cbc59040288056df2ad7f7f03bbcaca6b503c737ab8e73/cryptography-44.0.2-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7bdcd82189759aba3816d1f729ce42ffded1ac304c151d0a8e89b9996ab863d5", size = 3678122 },
{ url = "https://files.pythonhosted.org/packages/27/7b/664ea5e0d1eab511a10e480baf1c5d3e681c7d91718f60e149cec09edf01/cryptography-44.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4973da6ca3db4405c54cd0b26d328be54c7747e89e284fcff166132eb7bccc9c", size = 4191831 },
{ url = "https://files.pythonhosted.org/packages/2a/07/79554a9c40eb11345e1861f46f845fa71c9e25bf66d132e123d9feb8e7f9/cryptography-44.0.2-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4e389622b6927d8133f314949a9812972711a111d577a5d1f4bee5e58736b80a", size = 3960583 },
{ url = "https://files.pythonhosted.org/packages/bb/6d/858e356a49a4f0b591bd6789d821427de18432212e137290b6d8a817e9bf/cryptography-44.0.2-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f514ef4cd14bb6fb484b4a60203e912cfcb64f2ab139e88c2274511514bf7308", size = 4191753 },
{ url = "https://files.pythonhosted.org/packages/b2/80/62df41ba4916067fa6b125aa8c14d7e9181773f0d5d0bd4dcef580d8b7c6/cryptography-44.0.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1bc312dfb7a6e5d66082c87c34c8a62176e684b6fe3d90fcfe1568de675e6688", size = 4079550 },
{ url = "https://files.pythonhosted.org/packages/f3/cd/2558cc08f7b1bb40683f99ff4327f8dcfc7de3affc669e9065e14824511b/cryptography-44.0.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b721b8b4d948b218c88cb8c45a01793483821e709afe5f622861fc6182b20a7", size = 4298367 },
{ url = "https://files.pythonhosted.org/packages/71/59/94ccc74788945bc3bd4cf355d19867e8057ff5fdbcac781b1ff95b700fb1/cryptography-44.0.2-cp37-abi3-win32.whl", hash = "sha256:51e4de3af4ec3899d6d178a8c005226491c27c4ba84101bfb59c901e10ca9f79", size = 2772843 },
{ url = "https://files.pythonhosted.org/packages/ca/2c/0d0bbaf61ba05acb32f0841853cfa33ebb7a9ab3d9ed8bb004bd39f2da6a/cryptography-44.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:c505d61b6176aaf982c5717ce04e87da5abc9a36a5b39ac03905c4aafe8de7aa", size = 3209057 },
{ url = "https://files.pythonhosted.org/packages/9e/be/7a26142e6d0f7683d8a382dd963745e65db895a79a280a30525ec92be890/cryptography-44.0.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8e0ddd63e6bf1161800592c71ac794d3fb8001f2caebe0966e77c5234fa9efc3", size = 6677789 },
{ url = "https://files.pythonhosted.org/packages/06/88/638865be7198a84a7713950b1db7343391c6066a20e614f8fa286eb178ed/cryptography-44.0.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81276f0ea79a208d961c433a947029e1a15948966658cf6710bbabb60fcc2639", size = 3951919 },
{ url = "https://files.pythonhosted.org/packages/d7/fc/99fe639bcdf58561dfad1faa8a7369d1dc13f20acd78371bb97a01613585/cryptography-44.0.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a1e657c0f4ea2a23304ee3f964db058c9e9e635cc7019c4aa21c330755ef6fd", size = 4167812 },
{ url = "https://files.pythonhosted.org/packages/53/7b/aafe60210ec93d5d7f552592a28192e51d3c6b6be449e7fd0a91399b5d07/cryptography-44.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6210c05941994290f3f7f175a4a57dbbb2afd9273657614c506d5976db061181", size = 3958571 },
{ url = "https://files.pythonhosted.org/packages/16/32/051f7ce79ad5a6ef5e26a92b37f172ee2d6e1cce09931646eef8de1e9827/cryptography-44.0.2-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1c3572526997b36f245a96a2b1713bf79ce99b271bbcf084beb6b9b075f29ea", size = 3679832 },
{ url = "https://files.pythonhosted.org/packages/78/2b/999b2a1e1ba2206f2d3bca267d68f350beb2b048a41ea827e08ce7260098/cryptography-44.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b042d2a275c8cee83a4b7ae30c45a15e6a4baa65a179a0ec2d78ebb90e4f6699", size = 4193719 },
{ url = "https://files.pythonhosted.org/packages/72/97/430e56e39a1356e8e8f10f723211a0e256e11895ef1a135f30d7d40f2540/cryptography-44.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d03806036b4f89e3b13b6218fefea8d5312e450935b1a2d55f0524e2ed7c59d9", size = 3960852 },
{ url = "https://files.pythonhosted.org/packages/89/33/c1cf182c152e1d262cac56850939530c05ca6c8d149aa0dcee490b417e99/cryptography-44.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c7362add18b416b69d58c910caa217f980c5ef39b23a38a0880dfd87bdf8cd23", size = 4193906 },
{ url = "https://files.pythonhosted.org/packages/e1/99/87cf26d4f125380dc674233971069bc28d19b07f7755b29861570e513650/cryptography-44.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8cadc6e3b5a1f144a039ea08a0bdb03a2a92e19c46be3285123d32029f40a922", size = 4081572 },
{ url = "https://files.pythonhosted.org/packages/b3/9f/6a3e0391957cc0c5f84aef9fbdd763035f2b52e998a53f99345e3ac69312/cryptography-44.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f101b1f780f7fc613d040ca4bdf835c6ef3b00e9bd7125a4255ec574c7916e4", size = 4298631 },
{ url = "https://files.pythonhosted.org/packages/e2/a5/5bc097adb4b6d22a24dea53c51f37e480aaec3465285c253098642696423/cryptography-44.0.2-cp39-abi3-win32.whl", hash = "sha256:3dc62975e31617badc19a906481deacdeb80b4bb454394b4098e3f2525a488c5", size = 2773792 },
{ url = "https://files.pythonhosted.org/packages/33/cf/1f7649b8b9a3543e042d3f348e398a061923ac05b507f3f4d95f11938aa9/cryptography-44.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6", size = 3210957 },
{ url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018 },
]
[[package]]
name = "flatbuffers"
version = "25.2.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/30/eb5dce7994fc71a2f685d98ec33cc660c0a5887db5610137e60d8cbc4489/flatbuffers-25.2.10.tar.gz", hash = "sha256:97e451377a41262f8d9bd4295cc836133415cc03d8cb966410a4af92eb00d26e", size = 22170 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/25/155f9f080d5e4bc0082edfda032ea2bc2b8fab3f4d25d46c1e9dd22a1a89/flatbuffers-25.2.10-py2.py3-none-any.whl", hash = "sha256:ebba5f4d5ea615af3f7fd70fc310636fbb2bbd1f566ac0a23d98dd412de50051", size = 30953 },
]
[[package]]
name = "humanfriendly"
version = "10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyreadline3", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794 },
]
[[package]]
@@ -152,6 +117,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/95/8379140838cd95472de843e982d0bf674e8dbf25a899c44e2f76b15704d9/maturin-1.8.3-py3-none-win_arm64.whl", hash = "sha256:33939aabf9a06a8a14ca6c399d32616c7e574fcca8d4ff6dcd984441051f32fb", size = 6687772 },
]
[[package]]
name = "mpmath"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198 },
]
[[package]]
name = "numpy"
version = "2.2.4"
@@ -190,38 +164,95 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/05/eb7eec66b95cf697f08c754ef26c3549d03ebd682819f794cb039574a0a6/numpy-2.2.4-cp313-cp313t-win_amd64.whl", hash = "sha256:188dcbca89834cc2e14eb2f106c96d6d46f200fe0200310fc29089657379c58d", size = 12739119 },
]
[[package]]
name = "onnxruntime"
version = "1.22.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coloredlogs" },
{ name = "flatbuffers" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "protobuf" },
{ name = "sympy" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/de/9162872c6e502e9ac8c99a98a8738b2fab408123d11de55022ac4f92562a/onnxruntime-1.22.0-cp312-cp312-macosx_13_0_universal2.whl", hash = "sha256:f3c0380f53c1e72a41b3f4d6af2ccc01df2c17844072233442c3a7e74851ab97", size = 34298046 },
{ url = "https://files.pythonhosted.org/packages/03/79/36f910cd9fc96b444b0e728bba14607016079786adf032dae61f7c63b4aa/onnxruntime-1.22.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8601128eaef79b636152aea76ae6981b7c9fc81a618f584c15d78d42b310f1c", size = 14443220 },
{ url = "https://files.pythonhosted.org/packages/8c/60/16d219b8868cc8e8e51a68519873bdb9f5f24af080b62e917a13fff9989b/onnxruntime-1.22.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6964a975731afc19dc3418fad8d4e08c48920144ff590149429a5ebe0d15fb3c", size = 16406377 },
{ url = "https://files.pythonhosted.org/packages/36/b4/3f1c71ce1d3d21078a6a74c5483bfa2b07e41a8d2b8fb1e9993e6a26d8d3/onnxruntime-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0d534a43d1264d1273c2d4f00a5a588fa98d21117a3345b7104fa0bbcaadb9a", size = 12692233 },
{ url = "https://files.pythonhosted.org/packages/a9/65/5cb5018d5b0b7cba820d2c4a1d1b02d40df538d49138ba36a509457e4df6/onnxruntime-1.22.0-cp313-cp313-macosx_13_0_universal2.whl", hash = "sha256:fe7c051236aae16d8e2e9ffbfc1e115a0cc2450e873a9c4cb75c0cc96c1dae07", size = 34298715 },
{ url = "https://files.pythonhosted.org/packages/e1/89/1dfe1b368831d1256b90b95cb8d11da8ab769febd5c8833ec85ec1f79d21/onnxruntime-1.22.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a6bbed10bc5e770c04d422893d3045b81acbbadc9fb759a2cd1ca00993da919", size = 14443266 },
{ url = "https://files.pythonhosted.org/packages/1e/70/342514ade3a33ad9dd505dcee96ff1f0e7be6d0e6e9c911fe0f1505abf42/onnxruntime-1.22.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9fe45ee3e756300fccfd8d61b91129a121d3d80e9d38e01f03ff1295badc32b8", size = 16406707 },
{ url = "https://files.pythonhosted.org/packages/3e/89/2f64e250945fa87140fb917ba377d6d0e9122e029c8512f389a9b7f953f4/onnxruntime-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:5a31d84ef82b4b05d794a4ce8ba37b0d9deb768fd580e36e17b39e0b4840253b", size = 12691777 },
{ url = "https://files.pythonhosted.org/packages/9f/48/d61d5f1ed098161edd88c56cbac49207d7b7b149e613d2cd7e33176c63b3/onnxruntime-1.22.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a2ac5bd9205d831541db4e508e586e764a74f14efdd3f89af7fd20e1bf4a1ed", size = 14454003 },
{ url = "https://files.pythonhosted.org/packages/c3/16/873b955beda7bada5b0d798d3a601b2ff210e44ad5169f6d405b93892103/onnxruntime-1.22.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64845709f9e8a2809e8e009bc4c8f73b788cee9c6619b7d9930344eae4c9cd36", size = 16427482 },
]
[[package]]
name = "open-xiaoai-xiaozhi"
version = "1.0.0"
source = { editable = "." }
dependencies = [
{ name = "asyncio" },
{ name = "cryptography" },
{ name = "maturin" },
{ name = "numpy" },
{ name = "opuslib" },
{ name = "onnxruntime" },
{ name = "opuslib-next" },
{ name = "pyaudio" },
{ name = "pypinyin" },
{ name = "requests" },
{ name = "sentencepiece" },
{ name = "sherpa-onnx" },
{ name = "websockets" },
]
[package.metadata]
requires-dist = [
{ name = "asyncio", specifier = ">=3.4.3" },
{ name = "cryptography", specifier = ">=44.0.2" },
{ name = "maturin", specifier = ">=1.8.3" },
{ name = "numpy", specifier = ">=2.2.3" },
{ name = "opuslib", specifier = ">=3.0.1" },
{ name = "onnxruntime", specifier = ">=1.22.0" },
{ name = "opuslib-next", specifier = ">=1.1.4" },
{ name = "pyaudio", specifier = ">=0.2.14" },
{ name = "pypinyin", specifier = ">=0.54.0" },
{ name = "requests", specifier = ">=2.32.3" },
{ name = "sentencepiece", specifier = ">=0.2.0" },
{ name = "sherpa-onnx", specifier = ">=1.12.0" },
{ name = "websockets", specifier = ">=15.0.1" },
]
[[package]]
name = "opuslib"
version = "3.0.1"
name = "opuslib-next"
version = "1.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/46/55/826befabb29fd3902bad6d6d7308790894c7ad4d73f051728a0c53d37cd7/opuslib-3.0.1.tar.gz", hash = "sha256:2cb045e5b03e7fc50dfefe431e3404dddddbd8f5961c10c51e32dfb69a044c97", size = 8550 }
sdist = { url = "https://files.pythonhosted.org/packages/16/c9/0440b1c4f98c1bf1575ca3e3e3e18872c80d44c184b31d8d9ed1d04b9323/opuslib_next-1.1.4.tar.gz", hash = "sha256:415322be6c738ffef9df6cd7e79725880c1737ccf82c538af5c17b7bb6da60e9", size = 9363 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/a8/3674c6f3af230d692752cb2b2534c84c9e2f2e48b1221aa3354289e8cbea/opuslib_next-1.1.4-py3-none-any.whl", hash = "sha256:aefaf2ca1d2e0f279a4236045096d21f374581f2c33bf35657570e75028f8a55", size = 13435 },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 },
]
[[package]]
name = "protobuf"
version = "6.30.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c8/8c/cf2ac658216eebe49eaedf1e06bc06cbf6a143469236294a1171a51357c3/protobuf-6.30.2.tar.gz", hash = "sha256:35c859ae076d8c56054c25b59e5e59638d86545ed6e2b6efac6be0b6ea3ba048", size = 429315 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/85/cd53abe6a6cbf2e0029243d6ae5fb4335da2996f6c177bb2ce685068e43d/protobuf-6.30.2-cp310-abi3-win32.whl", hash = "sha256:b12ef7df7b9329886e66404bef5e9ce6a26b54069d7f7436a0853ccdeb91c103", size = 419148 },
{ url = "https://files.pythonhosted.org/packages/97/e9/7b9f1b259d509aef2b833c29a1f3c39185e2bf21c9c1be1cd11c22cb2149/protobuf-6.30.2-cp310-abi3-win_amd64.whl", hash = "sha256:7653c99774f73fe6b9301b87da52af0e69783a2e371e8b599b3e9cb4da4b12b9", size = 431003 },
{ url = "https://files.pythonhosted.org/packages/8e/66/7f3b121f59097c93267e7f497f10e52ced7161b38295137a12a266b6c149/protobuf-6.30.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:0eb523c550a66a09a0c20f86dd554afbf4d32b02af34ae53d93268c1f73bc65b", size = 417579 },
{ url = "https://files.pythonhosted.org/packages/d0/89/bbb1bff09600e662ad5b384420ad92de61cab2ed0f12ace1fd081fd4c295/protobuf-6.30.2-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:50f32cc9fd9cb09c783ebc275611b4f19dfdfb68d1ee55d2f0c7fa040df96815", size = 317319 },
{ url = "https://files.pythonhosted.org/packages/28/50/1925de813499546bc8ab3ae857e3ec84efe7d2f19b34529d0c7c3d02d11d/protobuf-6.30.2-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:4f6c687ae8efae6cf6093389a596548214467778146b7245e886f35e1485315d", size = 316212 },
{ url = "https://files.pythonhosted.org/packages/e5/a1/93c2acf4ade3c5b557d02d500b06798f4ed2c176fa03e3c34973ca92df7f/protobuf-6.30.2-py3-none-any.whl", hash = "sha256:ae86b030e69a98e08c77beab574cbcb9fff6d031d57209f574a5aea1445f4b51", size = 167062 },
]
[[package]]
name = "pyaudio"
@@ -236,12 +267,21 @@ wheels = [
]
[[package]]
name = "pycparser"
version = "2.22"
name = "pypinyin"
version = "0.54.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 }
sdist = { url = "https://files.pythonhosted.org/packages/c9/7f/81cb5416ddacfeccca8eeedcd3543a72b093b26d9c4ca7bde8beea733e4e/pypinyin-0.54.0.tar.gz", hash = "sha256:9ab0d07ff51d191529e22134a60e109d0526d80b7a80afa73da4c89521610958", size = 837455 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 },
{ url = "https://files.pythonhosted.org/packages/b6/ec/2c04ac863e7a85bb68b0b655cec2f19853d51d305ce3d785848db6037b8d/pypinyin-0.54.0-py2.py3-none-any.whl", hash = "sha256:5f776f19b9fd922e4121a114810b22048d90e6e8037fb1c07f4c40f987ae6e7a", size = 837012 },
]
[[package]]
name = "pyreadline3"
version = "3.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178 },
]
[[package]]
@@ -259,6 +299,56 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 },
]
[[package]]
name = "sentencepiece"
version = "0.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c9/d2/b9c7ca067c26d8ff085d252c89b5f69609ca93fb85a00ede95f4857865d4/sentencepiece-0.2.0.tar.gz", hash = "sha256:a52c19171daaf2e697dc6cbe67684e0fa341b1248966f6aebb541de654d15843", size = 2632106 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/5a/141b227ed54293360a9ffbb7bf8252b4e5efc0400cdeac5809340e5d2b21/sentencepiece-0.2.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:ea5f536e32ea8ec96086ee00d7a4a131ce583a1b18d130711707c10e69601cb2", size = 2409370 },
{ url = "https://files.pythonhosted.org/packages/2e/08/a4c135ad6fc2ce26798d14ab72790d66e813efc9589fd30a5316a88ca8d5/sentencepiece-0.2.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d0cb51f53b6aae3c36bafe41e86167c71af8370a039f542c43b0cce5ef24a68c", size = 1239288 },
{ url = "https://files.pythonhosted.org/packages/49/0a/2fe387f825ac5aad5a0bfe221904882106cac58e1b693ba7818785a882b6/sentencepiece-0.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3212121805afc58d8b00ab4e7dd1f8f76c203ddb9dc94aa4079618a31cf5da0f", size = 1181597 },
{ url = "https://files.pythonhosted.org/packages/cc/38/e4698ee2293fe4835dc033c49796a39b3eebd8752098f6bd0aa53a14af1f/sentencepiece-0.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a3149e3066c2a75e0d68a43eb632d7ae728c7925b517f4c05c40f6f7280ce08", size = 1259220 },
{ url = "https://files.pythonhosted.org/packages/12/24/fd7ef967c9dad2f6e6e5386d0cadaf65cda8b7be6e3861a9ab3121035139/sentencepiece-0.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:632f3594d3e7ac8b367bca204cb3fd05a01d5b21455acd097ea4c0e30e2f63d7", size = 1355962 },
{ url = "https://files.pythonhosted.org/packages/4f/d2/18246f43ca730bb81918f87b7e886531eda32d835811ad9f4657c54eee35/sentencepiece-0.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f295105c6bdbb05bd5e1b0cafbd78ff95036f5d3641e7949455a3f4e5e7c3109", size = 1301706 },
{ url = "https://files.pythonhosted.org/packages/8a/47/ca237b562f420044ab56ddb4c278672f7e8c866e183730a20e413b38a989/sentencepiece-0.2.0-cp312-cp312-win32.whl", hash = "sha256:fb89f811e5efd18bab141afc3fea3de141c3f69f3fe9e898f710ae7fe3aab251", size = 936941 },
{ url = "https://files.pythonhosted.org/packages/c6/97/d159c32642306ee2b70732077632895438867b3b6df282354bd550cf2a67/sentencepiece-0.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7a673a72aab81fef5ebe755c6e0cc60087d1f3a4700835d40537183c1703a45f", size = 991994 },
]
[[package]]
name = "sherpa-onnx"
version = "1.12.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ab/81/64f21ee729ef0c21f645048c05201b0657b52ea2ada9e65ed2a1b69c1dd2/sherpa-onnx-1.12.0.tar.gz", hash = "sha256:91d912ad102bd4149ddd2dcb3a2bebfeecc34ad903eaeae89dc7fbf11103f62a", size = 530627 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/50/3ff72177343d538341091102f9e139f4a0bbb89797ae78ecd4c479d015f5/sherpa_onnx-1.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e7efc658647bcbd053879415755eff5fd96964d97ac3385be89c197a98182aa", size = 18166756 },
{ url = "https://files.pythonhosted.org/packages/bd/13/a7cfdd1edba5339c68a5de8c20e0d8621fef962ac4171a79772cefc79a8f/sherpa_onnx-1.12.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:cf62d2ad4a494debb2335298ac38f7ce98a4821ce3397a1d35248cb3978a51d9", size = 38783152 },
{ url = "https://files.pythonhosted.org/packages/25/ec/ebb543554538633d29171de2264fdf5fd8eb680c04c98e7fa9e249cb57a5/sherpa_onnx-1.12.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:5bc6bac071bf7f8f83113c760f932d154fd636f8a5b56fe97e199f7b95a18272", size = 20664664 },
{ url = "https://files.pythonhosted.org/packages/ae/1c/55d16fad62cfbe24239eb963cd3c9ba9906ad6c56772eaeab84211c7f1ab/sherpa_onnx-1.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4084a131bab3b6b5974b7c6472b09048c7f0b2d0f9b0b26171de8bff5bf91aa8", size = 23013320 },
{ url = "https://files.pythonhosted.org/packages/34/5f/2b1b736e745911b745d323e970b0452238af537a79b4d10f40e1326d642c/sherpa_onnx-1.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d2078be1ef5d1d65822de21adca3b7e6353f3859dad02eb1cfd71c676e410ef", size = 24909403 },
{ url = "https://files.pythonhosted.org/packages/4c/88/06ef64d952c69c714c5f4fb22d83e86727f0567790663fd059446253ea72/sherpa_onnx-1.12.0-cp312-cp312-win32.whl", hash = "sha256:91d0aeaa1f6e5fb91b9b4a1326e67691a3f306a61a499a75f4c437697f8596ef", size = 20842198 },
{ url = "https://files.pythonhosted.org/packages/34/c1/e2ccebbf1931235850f734f420c0e719247648f53dc1011b528bb9bcef55/sherpa_onnx-1.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:8df63f2235adc43fe735d07d0ca5e910337ebfeea03bc22931527b6a0231e505", size = 23667903 },
{ url = "https://files.pythonhosted.org/packages/ae/eb/4f8eeff8661933e8f6c69f44a5f29b49a1d7a156517737f0cf1c7dbfa54f/sherpa_onnx-1.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15725f20a19ef9fb31c73e79a2aad53f2b962bef0f13ed15fd6b02c71f8c4904", size = 18166754 },
{ url = "https://files.pythonhosted.org/packages/29/00/57f967161782b78ebbbdd29ea54060e17cece2818a9e3f93cdee18739555/sherpa_onnx-1.12.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:74bc0bbc8695dff73acb620c0d3d4c5dd5d89aa0401d5cf31f6e15150b33947b", size = 38783224 },
{ url = "https://files.pythonhosted.org/packages/5c/0d/9186b91274e2d6711884bb88a6b3c8d5264d967e44c461839e90d9ceb931/sherpa_onnx-1.12.0-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:ced07e99b511b899d999d54f6f749c8200ef92df05cc6439dca8ca7da4f3800f", size = 20664831 },
{ url = "https://files.pythonhosted.org/packages/06/65/bb281e9309f83a7e69af403a06dc0ed6a96d9708adb486051d2dbdf782db/sherpa_onnx-1.12.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:111e270e7da787a578904268108de90284060a83c88721eac0de773407e84d97", size = 23013541 },
{ url = "https://files.pythonhosted.org/packages/ec/d4/2048c2e91881bdbb3139f60ccf52c511d8065d4a46ca102cbcd6081c79b7/sherpa_onnx-1.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:92a7ae7ce0d4b70417c03fdbcad8946eba739f2968688d87c501eea5bf97a909", size = 24909452 },
{ url = "https://files.pythonhosted.org/packages/42/d0/1485dd93cba8d018e82edbc21d63946e679f075335404367ed2ca9a4d5b0/sherpa_onnx-1.12.0-cp313-cp313-win32.whl", hash = "sha256:801b592a596c09a97e415813d5656a71a6f68655b000748f136cf66db69cbe4d", size = 20838807 },
{ url = "https://files.pythonhosted.org/packages/c4/5f/4a0cf4e8adc9d1aa299fbb87a379bc73737eb202bebccef9f0d09805e0d5/sherpa_onnx-1.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:46cf8ccd5d360e1b02a2668d8416b746c624b78f8b7346f61d4e9fb6eab53c07", size = 23667855 },
]
[[package]]
name = "sympy"
version = "1.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mpmath" },
]
sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353 },
]
[[package]]
name = "urllib3"
version = "2.3.0"
+159
View File
@@ -0,0 +1,159 @@
import asyncio
from config import APP_CONFIG
from xiaozhi.ref import get_audio_codec, get_speaker, get_vad, get_xiaoai, get_xiaozhi
from xiaozhi.services.protocols.typing import AbortReason, DeviceState, ListeningMode
class Step:
idle = "idle"
on_interrupt = "on_interrupt"
on_wakeup = "on_wakeup"
on_tts_start = "on_tts_start"
on_tts_end = "on_tts_end"
on_speech = "on_speech"
on_silence = "on_silence"
class __EventManager:
def __init__(self):
self.session_id = 0
self.current_step = Step.idle
self.next_step_future = None
def update_step(self, step: Step, step_data=None):
if get_xiaoai().mode == "xiaozhi":
return
self.current_step = step
if self.next_step_future:
get_xiaoai().async_loop.call_soon_threadsafe(
self.next_step_future.set_result, (step, step_data)
)
self.next_step_future = None
async def wait_next_step(self, timeout=None) -> Step | None:
current_session = self.session_id
self.next_step_future = get_xiaoai().async_loop.create_future()
async def _timeout(timeout):
idx = 0
while idx < timeout:
idx += 1
await asyncio.sleep(1)
return ("timeout", None)
futures = [self.next_step_future]
if timeout:
futures.append(get_xiaoai().async_loop.create_task(_timeout(timeout)))
done, _ = await asyncio.wait(
futures,
return_when=asyncio.FIRST_COMPLETED,
)
if current_session != self.session_id:
# 当前 session 已经结束
return ("interrupted", None)
return list(done)[0].result()
def on_interrupt(self):
"""用户打断(小爱同学)"""
self.session_id = self.session_id + 1
self.update_step(Step.on_interrupt)
self.start_session()
def on_wakeup(self):
"""用户唤醒(你好小智)"""
self.session_id = self.session_id + 1
self.update_step(Step.on_wakeup)
self.start_session()
def on_tts_end(self):
"""TTS结束"""
if self.current_step == Step.on_interrupt:
# 当前 session 已经被打断了,不再处理
return
self.session_id = self.session_id + 1
self.update_step(Step.on_tts_end)
self.start_session()
def on_tts_start(self):
"""TTS结束"""
self.update_step(Step.on_tts_start)
def on_speech(self, speech_buffer: bytes):
"""检测到声音(开始说话"""
self.update_step(Step.on_speech, speech_buffer)
def on_silence(self):
"""检测到静音(说话结束)"""
self.update_step(Step.on_silence)
def start_session(self):
asyncio.run_coroutine_threadsafe(
self.__start_session(), get_xiaoai().async_loop
)
async def __start_session(self):
if get_xiaoai().mode == "xiaozhi":
return
vad = get_vad()
codec = get_audio_codec()
speaker = get_speaker()
xiaozhi = get_xiaozhi()
# 先取消之前的 VAD 检测和音频输入输出流
xiaozhi.set_device_state(DeviceState.IDLE)
await xiaozhi.protocol.send_abort_speaking(AbortReason.ABORT)
# 小爱同学唤醒时,直接打断
if self.current_step == Step.on_interrupt:
return
# 尝试打开音频通道
if not xiaozhi.protocol.is_audio_channel_opened():
xiaozhi.set_device_state(DeviceState.CONNECTING)
await xiaozhi.protocol.open_audio_channel()
# 等待 TTS 余音结束
if self.current_step in [Step.on_tts_end]:
vad.resume("silence")
step, _ = await self.wait_next_step()
if step != Step.on_silence:
return
# 检查是否有人说话
vad.resume("speech")
step, speech_buffer = await self.wait_next_step(
timeout=APP_CONFIG["wakeup"]["timeout"]
)
if step == "timeout":
# 如果没人说话,则回到 IDLE 状态
xiaozhi.set_device_state(DeviceState.IDLE)
print("👋 已退出唤醒")
after_wakeup = APP_CONFIG["wakeup"]["after_wakeup"]
await after_wakeup(speaker)
return
if step != Step.on_speech:
return
# 开始说话
await xiaozhi.protocol.send_start_listening(ListeningMode.MANUAL)
codec.input_stream.input(speech_buffer) # 追加音频输入片段
xiaozhi.set_device_state(DeviceState.LISTENING)
# 等待说话结束
vad.resume("silence")
step, _ = await self.wait_next_step()
if step != Step.on_silence:
return
# 停止说话
await xiaozhi.protocol.send_stop_listening()
xiaozhi.set_device_state(DeviceState.IDLE)
EventManager = __EventManager()
@@ -0,0 +1,7 @@
天 猫 精 灵 @天猫精灵
小 度 小 度 @小度小度
豆 包 豆 包 @豆包豆包
你 好 小 智 @你好小智
你 好 小 爱 @你好小爱
▁HI ▁S I RI
▁HE Y ▁S I RI
+51
View File
@@ -0,0 +1,51 @@
from typing import Any
GLOBAL_STATES = {}
def set_xiaozhi(xiaozhi: Any):
GLOBAL_STATES["xiaozhi"] = xiaozhi
def get_xiaozhi() -> Any:
return GLOBAL_STATES.get("xiaozhi")
def set_xiaoai(xiaoai: Any):
GLOBAL_STATES["xiaoai"] = xiaoai
def get_xiaoai() -> Any:
return GLOBAL_STATES.get("xiaoai")
def set_vad(vad: Any):
GLOBAL_STATES["vad"] = vad
def get_vad() -> Any:
return GLOBAL_STATES.get("vad")
def set_audio_codec(opus_encoder: Any):
GLOBAL_STATES["opus_encoder"] = opus_encoder
def get_audio_codec() -> Any:
return GLOBAL_STATES.get("opus_encoder")
def get_speaker() -> Any:
return GLOBAL_STATES.get("speaker")
def set_speaker(speaker: Any):
GLOBAL_STATES["speaker"] = speaker
def set_kws(kws: Any):
GLOBAL_STATES["kws"] = kws
def get_kws() -> Any:
return GLOBAL_STATES.get("kws")
+75 -194
View File
@@ -1,17 +1,8 @@
import logging
import queue
import sys
import time
import numpy as np
import opuslib
import pyaudio
import opuslib_next as opuslib
from xiaozhi.ref import set_audio_codec
from xiaozhi.services.audio.stream import MyAudio
from xiaozhi.services.protocols.typing import AudioConfig
from xiaozhi.xiaoai import XiaoAI
logger = logging.getLogger("AudioCodec")
class AudioCodec:
@@ -24,50 +15,46 @@ class AudioCodec:
self.output_stream = None
self.opus_encoder = None
self.opus_decoder = None
self.audio_decode_queue = queue.Queue()
self._is_closing = False
self._initialize_audio()
set_audio_codec(self)
def _initialize_audio(self):
"""初始化音频设备和编解码器"""
try:
self.audio = MyAudio() if XiaoAI.mode == "xiaoai" else pyaudio.PyAudio()
self.audio = MyAudio.create()
# 初始化音频输入流
self.input_stream = self.audio.open(
format=pyaudio.paInt16,
channels=AudioConfig.CHANNELS,
rate=AudioConfig.SAMPLE_RATE,
input=True,
frames_per_buffer=AudioConfig.FRAME_SIZE,
)
# 初始化音频输入流
self.input_stream = self.audio.open(
format=AudioConfig.FORMAT,
channels=AudioConfig.CHANNELS,
rate=AudioConfig.SAMPLE_RATE,
input=True,
frames_per_buffer=AudioConfig.FRAME_SIZE,
input_device_index=MyAudio.get_input_device_index(self.audio),
)
# 初始化音频输出流
self.output_stream = self.audio.open(
format=pyaudio.paInt16,
channels=AudioConfig.CHANNELS,
rate=AudioConfig.SAMPLE_RATE,
output=True,
frames_per_buffer=AudioConfig.FRAME_SIZE,
)
# 初始化音频输出流
self.output_stream = self.audio.open(
format=AudioConfig.FORMAT,
channels=AudioConfig.CHANNELS,
rate=AudioConfig.SAMPLE_RATE,
output=True,
frames_per_buffer=AudioConfig.FRAME_SIZE,
output_device_index=MyAudio.get_output_device_index(self.audio),
)
# 初始化Opus编码器
self.opus_encoder = opuslib.Encoder(
fs=AudioConfig.SAMPLE_RATE,
channels=AudioConfig.CHANNELS,
application=opuslib.APPLICATION_AUDIO,
)
# 初始化Opus编码器
self.opus_encoder = opuslib.Encoder(
fs=AudioConfig.SAMPLE_RATE,
channels=AudioConfig.CHANNELS,
application=opuslib.APPLICATION_AUDIO,
)
# 初始化Opus解码器
self.opus_decoder = opuslib.Decoder(
fs=AudioConfig.SAMPLE_RATE, channels=AudioConfig.CHANNELS
)
logger.info("音频设备和编解码器初始化成功")
except Exception as e:
logger.error(f"初始化音频设备失败: {e}")
raise
# 初始化Opus解码器
self.opus_decoder = opuslib.Decoder(
fs=AudioConfig.SAMPLE_RATE, channels=AudioConfig.CHANNELS
)
def read_audio(self):
"""读取音频输入数据并编码"""
@@ -78,96 +65,35 @@ class AudioCodec:
if not data:
return None
return self.opus_encoder.encode(data, AudioConfig.FRAME_SIZE)
except Exception as e:
logger.error(f"读取音频输入时出错: {e}")
except Exception:
return None
def write_audio(self, opus_data):
"""将编码的音频数据添加到播放队列"""
self.audio_decode_queue.put(opus_data)
def play_audio(self):
"""处理并播放队列中的音频数据"""
"""解码并播放"""
try:
# 批量处理多个音频包以减少处理延迟
batch_size = min(10, self.audio_decode_queue.qsize())
if batch_size == 0:
return False
# 创建缓冲区存储解码后的数据
buffer = bytearray()
for _ in range(batch_size):
if self.audio_decode_queue.empty():
break
opus_data = self.audio_decode_queue.get_nowait()
try:
pcm_data = self.opus_decoder.decode(
opus_data, AudioConfig.FRAME_SIZE, decode_fec=False
)
buffer.extend(pcm_data)
except Exception as e:
logger.error(f"解码音频数据时出错: {e}")
# 只有在有数据时才处理和播放
if len(buffer) > 0:
# 转换为numpy数组
pcm_array = np.frombuffer(buffer, dtype=np.int16)
# 播放音频
try:
if self.output_stream and self.output_stream.is_active():
self.output_stream.write(pcm_array.tobytes())
return True
else:
# MAC 特定:如果流不活跃,尝试重新初始化
self._reinitialize_output_stream()
if self.output_stream and self.output_stream.is_active():
self.output_stream.write(pcm_array.tobytes())
return True
except OSError as e:
if "Stream closed" in str(e) or "Internal PortAudio error" in str(
e
):
logger.error(f"播放音频时出错: {e}")
self._reinitialize_output_stream()
else:
logger.error(f"播放音频时出错: {e}")
except queue.Empty:
pcm_data = self.decode_audio(opus_data) # 解码
self.output_stream.write(pcm_data) # 播放
except Exception:
pass
except Exception as e:
logger.error(f"播放音频时出错: {e}")
self._reinitialize_output_stream()
return False
def decode_audio(self, opus_data, frame_size=AudioConfig.FRAME_SIZE):
"""解码音频数据"""
return self.opus_decoder.decode(opus_data, frame_size, decode_fec=False)
def has_pending_audio(self):
"""检查是否还有待播放的音频数据"""
return not self.audio_decode_queue.empty()
def wait_for_audio_complete(self, timeout=5.0):
# 等待音频队列清空
attempt = 0
max_attempts = 15
while not self.audio_decode_queue.empty() and attempt < max_attempts:
time.sleep(0.1)
attempt += 1
# 在关闭前清空任何剩余数据
while not self.audio_decode_queue.empty():
try:
self.audio_decode_queue.get_nowait()
except queue.Empty:
break
def clear_audio_queue(self):
"""清空音频队列"""
while not self.audio_decode_queue.empty():
try:
self.audio_decode_queue.get_nowait()
except queue.Empty:
break
def encode_audio(self, buffer, frame_size=AudioConfig.FRAME_SIZE):
"""编码音频数据"""
try:
opus_frames = []
for i in range(0, len(buffer), frame_size * 2):
chunk = buffer[i : i + frame_size * 2]
if len(chunk) < frame_size * 2:
# 如果 buffer 长度不是 FRAME_SIZE 的 2 倍,需要补齐
chunk += b"\x00" * (frame_size * 2 - len(chunk))
opus_frame = self.opus_encoder.encode(chunk, frame_size)
opus_frames.append(opus_frame)
return opus_frames
except Exception:
return None
def start_streams(self):
"""启动音频流"""
@@ -178,95 +104,50 @@ class AudioCodec:
def stop_streams(self):
"""停止音频流"""
if self.input_stream and self.input_stream.is_active():
if self.input_stream.is_active():
self.input_stream.stop_stream()
if self.output_stream and self.output_stream.is_active():
if self.output_stream.is_active():
self.output_stream.stop_stream()
def _reinitialize_output_stream(self):
"""重新初始化音频输出流"""
if self._is_closing: # 如果正在关闭,不要重新初始化
def close(self):
"""关闭音频编解码器,确保资源正确释放"""
if self._is_closing: # 防止重复关闭
return
self._is_closing = True
try:
# 关闭输入流
if self.input_stream:
try:
if self.input_stream.is_active():
self.input_stream.stop_stream()
self.input_stream.close()
except Exception:
pass
self.input_stream = None
# 关闭输出流
if self.output_stream:
try:
if self.output_stream.is_active():
self.output_stream.stop_stream()
self.output_stream.close()
except Exception:
# logger.warning(f"关闭旧输出流时出错: {e}")
pass
# 在 MAC 上添加短暂延迟
if sys.platform in ("darwin", "linux"):
time.sleep(0.1)
self.output_stream = self.audio.open(
format=pyaudio.paInt16,
channels=AudioConfig.CHANNELS,
rate=AudioConfig.SAMPLE_RATE,
output=True,
frames_per_buffer=AudioConfig.FRAME_SIZE,
)
logger.info("音频输出流重新初始化成功")
except Exception as e:
logger.error(f"重新初始化音频输出流失败: {e}")
raise
def close(self):
"""关闭音频编解码器,确保资源正确释放"""
if self._is_closing: # 防止重复关闭
return
self._is_closing = True
logger.info("开始关闭音频编解码器...")
try:
# 等待并清理剩余音频数据
self.wait_for_audio_complete()
# 关闭输入流
if self.input_stream:
logger.debug("正在关闭输入流...")
try:
if self.input_stream.is_active():
self.input_stream.stop_stream()
self.input_stream.close()
except Exception as e:
logger.error(f"关闭输入流时出错: {e}")
self.input_stream = None
# 关闭输出流
if self.output_stream:
logger.debug("正在关闭输出流...")
try:
if self.output_stream.is_active():
self.output_stream.stop_stream()
self.output_stream.close()
except Exception as e:
logger.error(f"关闭输出流时出错: {e}")
self.output_stream = None
# 关闭 PyAudio 实例
if self.audio:
logger.debug("正在终止 PyAudio...")
try:
self.audio.terminate()
except Exception as e:
logger.error(f"终止 PyAudio 时出错: {e}")
except Exception:
pass
self.audio = None
# 清理编解码器
self.opus_encoder = None
self.opus_decoder = None
logger.info("音频编解码器关闭完成")
except Exception as e:
logger.error(f"关闭音频编解码器时发生错误: {e}")
except Exception:
pass
finally:
self._is_closing = False
def __del__(self):
"""析构函数,确保资源被释放"""
self.close()
@@ -0,0 +1,73 @@
import asyncio
import os
import threading
import time
from config import APP_CONFIG
from xiaozhi.event import EventManager
from xiaozhi.ref import get_speaker, get_xiaoai, get_xiaozhi, set_kws
from xiaozhi.services.audio.kws.sherpa import SherpaOnnx
from xiaozhi.services.audio.stream import MyAudio
from xiaozhi.services.protocols.typing import AudioConfig, DeviceState
from xiaozhi.utils.base import get_env
class _KWS:
def __init__(self):
set_kws(self)
def start(self):
if not get_env("CLI"):
return
self.audio = MyAudio.create()
self.stream = self.audio.open(
format=AudioConfig.FORMAT,
channels=1,
rate=16000,
input=True,
frames_per_buffer=AudioConfig.FRAME_SIZE,
start=True,
)
# 启动 KWS 服务
self.thread = threading.Thread(target=self._detection_loop, daemon=True)
self.thread.start()
def get_file_path(self, file_name: str):
current_dir = os.path.dirname(os.path.abspath(__file__))
return os.path.join(current_dir, "../../../models", file_name)
def _detection_loop(self):
SherpaOnnx.start()
self.stream.start_stream()
while True:
# 读取缓冲区音频数据
frames = self.stream.read()
# 在说话和监听状态时,暂停 KWS
if not frames or get_xiaozhi().device_state in [
DeviceState.LISTENING,
DeviceState.SPEAKING,
]:
time.sleep(0.01)
continue
result = SherpaOnnx.kws(frames)
if result:
print(f"🔥 触发唤醒: {result}")
self.on_message(result)
def on_message(self, text: str):
asyncio.run_coroutine_threadsafe(
self._on_message(text), get_xiaoai().async_loop
)
async def _on_message(self, text: str):
before_wakeup = APP_CONFIG["wakeup"]["before_wakeup"]
wakeup = await before_wakeup(get_speaker(), text, "kws")
if wakeup:
EventManager.on_wakeup()
KWS = _KWS()
@@ -0,0 +1,52 @@
import re
from sherpa_onnx import text2token
def init_project_context():
"""动态导入父模块"""
import os
import sys
project_root = os.path.abspath(
os.path.join(os.path.dirname(__file__), "../../../..")
)
if project_root not in sys.path:
sys.path.insert(0, project_root)
init_project_context()
from config import APP_CONFIG
from xiaozhi.utils.file import get_model_file_path
def get_args():
tokens_type = "cjkchar+bpe"
tokens = get_model_file_path("tokens.txt")
bpe_model = get_model_file_path("bpe.model")
output = get_model_file_path("keywords.txt")
keywords = APP_CONFIG["wakeup"]["keywords"]
texts = [f"{keyword.upper()}" for keyword in keywords]
return locals()
def main():
args = get_args()
encoded_texts = text2token(
args["texts"],
tokens=args["tokens"],
tokens_type=args["tokens_type"],
bpe_model=args["bpe_model"],
)
with open(args["output"], "w", encoding="utf8") as f:
for _, txt in enumerate(encoded_texts):
line = "".join(txt)
if re.match(r"^[▁A-Z\s]+$", line):
f.write(" ".join(txt) + "\n")
else:
f.write(" ".join(txt) + f" @{line}" + "\n")
if __name__ == "__main__":
main()
@@ -0,0 +1,36 @@
import numpy as np
import sherpa_onnx
from xiaozhi.utils.file import get_model_file_path
class _SherpaOnnx:
def start(self):
self.keyword_spotter = sherpa_onnx.KeywordSpotter(
provider="cpu",
num_threads=1,
max_active_paths=4,
keywords_score=2.0,
keywords_threshold=0.2,
num_trailing_blanks=1,
keywords_file=get_model_file_path("keywords.txt"),
tokens=get_model_file_path("tokens.txt"),
encoder=get_model_file_path("encoder.onnx"),
decoder=get_model_file_path("decoder.onnx"),
joiner=get_model_file_path("joiner.onnx"),
)
self.stream = self.keyword_spotter.create_stream()
def kws(self, frames):
samples = np.frombuffer(frames, dtype=np.int16)
samples = samples.astype(np.float32) / 32768.0
self.stream.accept_waveform(16000, samples)
while self.keyword_spotter.is_ready(self.stream):
self.keyword_spotter.decode_stream(self.stream)
result = self.keyword_spotter.get_result(self.stream)
if result:
self.keyword_spotter.reset_stream(self.stream)
return result.lower()
SherpaOnnx = _SherpaOnnx()
Binary file not shown.
@@ -1,45 +0,0 @@
import os
import sys
import subprocess
import logging
logger = logging.getLogger("Opus")
def setup_opus():
libs_dir = ""
lib_path = ""
logger.info("正在加载 Opus 动态库,请耐心等待...")
if sys.platform == "win32":
libs_dir = os.path.dirname(os.path.abspath(__file__))
lib_path = os.path.join(libs_dir, "opus.dll")
elif sys.platform == "darwin":
result = subprocess.check_output(
"brew list opus | grep libopus.dylib", shell=True
)
lib_path = result.decode("utf-8").strip()
if not lib_path.endswith("libopus.dylib"):
raise RuntimeError("请先安装 Opus brew install opus")
libs_dir = os.path.dirname(lib_path)
else:
raise RuntimeError(f"暂不支持 {sys.platform} 平台")
import ctypes.util
original_find_library = ctypes.util.find_library
def patched_find_library(name):
if name == "opus":
return lib_path
return original_find_library(name)
ctypes.util.find_library = patched_find_library
if hasattr(os, "add_dll_directory"):
os.add_dll_directory(libs_dir)
os.environ["PATH"] = libs_dir + os.pathsep + os.environ.get("PATH", "")
ctypes.CDLL(lib_path)
+101 -199
View File
@@ -1,159 +1,38 @@
from typing import ClassVar, Optional, Callable, Any
from threading import Lock
from collections import deque
import uuid
from typing import Any, Callable, ClassVar, Optional
import numpy as np
from config import APP_CONFIG
from xiaozhi.ref import get_xiaoai
class GlobalStream:
"""
全局音频缓冲区,用于存储和分发音频数据
class __GlobalStream:
def __init__(self):
self.readers = {}
self.on_output_data = None
用来将小爱音箱的音频输入输出流,适配到小智 AI 的音频输入输出流
"""
def register_reader(self, reader):
if reader.id not in self.readers:
self.readers[reader.id] = reader
_instance = None
_lock = Lock()
def unregister_reader(self, reader) -> None:
if reader.id in self.readers:
del self.readers[reader.id]
# 默认缓冲区大小(以字节为单位)
DEFAULT_BUFFER_SIZE = 1024 * 1024 # 1MB
def __new__(cls):
with cls._lock:
if cls._instance is None:
cls._instance = super(GlobalStream, cls).__new__(cls)
cls._instance._initialize()
return cls._instance
def _initialize(self):
"""初始化实例变量"""
self._max_buffer_size = self.DEFAULT_BUFFER_SIZE
self._input_buffer = deque(maxlen=self._max_buffer_size)
self._is_input_active = False
self._is_output_active = False
self._input_readers = {} # 跟踪每个流的读取位置
self._reader_counter = 0 # 为每个读取器分配唯一ID
self._buffer_overflow_count = 0 # 记录缓冲区溢出次数
self.on_output_data = None # 输入数据回调函数
def set_buffer_size(self, frames: int) -> None:
if frames <= 0:
raise ValueError("缓冲区大小必须大于0")
# 创建新的有限长度缓冲区
new_input_buffer = deque(self._input_buffer, maxlen=frames * 2)
# 替换旧缓冲区
self._input_buffer = new_input_buffer
self._max_buffer_size = frames * 2
# 重置读取位置,因为缓冲区可能已经改变
for reader_id in self._input_readers:
self._input_readers[reader_id] = 0
def register_reader(self) -> int:
"""注册一个新的读取器并返回其ID"""
reader_id = self._reader_counter
self._input_readers[reader_id] = 0 # 初始位置为0
self._reader_counter += 1
return reader_id
def unregister_reader(self, reader_id: int) -> None:
"""注销一个读取器"""
if reader_id in self._input_readers:
del self._input_readers[reader_id]
def read(self, reader_id: int, num_frames: int) -> bytes:
num_frames = num_frames * 2
if not self._is_input_active:
return bytes(num_frames)
if reader_id not in self._input_readers:
return bytes(num_frames)
# 将输入缓冲区转换为列表以便随机访问
buffer_list = list(self._input_buffer)
current_pos = self._input_readers[reader_id]
# 如果当前位置超出缓冲区大小,返回空字节
if current_pos >= len(buffer_list):
return bytes(num_frames)
# 读取数据
end_pos = min(current_pos + num_frames, len(buffer_list))
data = bytes(buffer_list[current_pos:end_pos])
# 更新读取位置
self._input_readers[reader_id] = end_pos
# 如果数据不足,用零填充
if len(data) < num_frames:
data += bytes(num_frames - len(data))
return data
# 转发输出音频流
def write(self, frames: bytes) -> None:
"""写入数据到输出缓冲区"""
if not self._is_output_active:
return
def input(self, data: bytes) -> None:
for key in self.readers:
self.readers[key].input(data)
def output(self, frames: bytes) -> None:
if self.on_output_data:
self.on_output_data(frames)
# 添加输入音频流
def add_input_data(self, data: bytes) -> None:
if not self._is_input_active:
return
"""添加输入数据到全局缓冲区"""
# 检查是否会溢出
if len(self._input_buffer) + len(data) > self._max_buffer_size:
self._buffer_overflow_count += 1
for b in data:
self._input_buffer.append(b)
# 如果有读取器的位置已经超出了缓冲区大小,需要调整
if len(self._input_buffer) >= self._max_buffer_size:
for reader_id in self._input_readers:
if self._input_readers[reader_id] > len(self._input_buffer) // 2:
# 将读取位置重置到缓冲区中间,避免读取器永远跟不上
self._input_readers[reader_id] = len(self._input_buffer) // 2
def start_input(self) -> None:
"""启动输入流"""
self._is_input_active = True
def stop_input(self) -> None:
"""停止输入流"""
self._is_input_active = False
self.clear()
def start_output(self) -> None:
"""启动输出流"""
self._is_output_active = True
def stop_output(self) -> None:
"""停止输出流"""
self._is_output_active = False
def is_input_active(self) -> bool:
"""检查输入流是否活跃"""
return self._is_input_active
def is_output_active(self) -> bool:
"""检查输出流是否活跃"""
return self._is_output_active
def clear(self) -> None:
"""清空缓冲区"""
self._input_buffer.clear()
for reader_id in self._input_readers:
self._input_readers[reader_id] = 0
GlobalStream = __GlobalStream()
class MyStream:
"""音频流类,用于读写音频数据"""
def __init__(
self,
rate: int,
@@ -164,6 +43,7 @@ class MyStream:
frames_per_buffer: int = 1024,
start: bool = True,
) -> None:
self.id = uuid.uuid4()
self._rate = rate
self._channels = channels
self._format = format
@@ -171,79 +51,112 @@ class MyStream:
self._is_input = input
self._is_output = output
self._is_active = False
self._is_closed = False
# 获取全局音频缓冲区
self._global_buffer = GlobalStream()
self.input_bytes: list[int] = []
# 注册读取器ID
self._reader_id = self._global_buffer.register_reader() if input else None
# 如果需要,启动流
if start:
self.start_stream()
def close(self) -> None:
"""关闭流"""
if not self._is_closed:
self.stop_stream()
if self._is_input and self._reader_id is not None:
self._global_buffer.unregister_reader(self._reader_id)
self._is_closed = True
self.stop_stream()
def is_active(self) -> bool:
"""检查流是否活跃"""
return self._is_active
def start_stream(self) -> None:
"""启动流"""
if not self._is_active and not self._is_closed:
if not self._is_active:
self._is_active = True
if self._is_input:
self._global_buffer.start_input()
if self._is_output:
self._global_buffer.start_output()
GlobalStream.register_reader(self)
def stop_stream(self) -> None:
"""停止流"""
if self._is_active:
self._is_active = False
if self._is_input:
self._global_buffer.stop_input()
if self._is_output:
self._global_buffer.stop_output()
def read(self, num_frames: int, exception_on_overflow=False) -> bytes:
"""从输入流读取数据"""
if (
not self._is_input
or self._is_closed
or not self._is_active
or self._reader_id is None
):
return bytes(num_frames)
return self._global_buffer.read(self._reader_id, num_frames)
GlobalStream.unregister_reader(self)
self.input_bytes = []
def write(self, frames: bytes) -> None:
"""写入数据到输出流"""
if not self._is_output or self._is_closed or not self._is_active:
# 发送输出音频流到扬声器
if not self._is_output or not self._is_active:
return
GlobalStream.output(frames)
def input(self, data: bytes):
# 收到麦克风输入音频流
if not self._is_input or not self._is_active:
return
self._global_buffer.write(frames)
if len(data) > 0:
samples = np.frombuffer(data, dtype=np.int16)
# 小爱音箱录音音量较小,需要后期放大一下
samples = samples * APP_CONFIG["vad"]["boost"]
self.input_bytes.extend(samples.tobytes())
def read(self, num_frames=None, exception_on_overflow=False) -> bytes:
if num_frames is None:
data = bytes(self.input_bytes)
self.input_bytes = []
return data
num_frames = num_frames * 2
if (
not self._is_input
or not self._is_active
# 达不到预期长度时,返回空字节,等待下一次读取
or len(self.input_bytes) < num_frames
):
return bytes([])
data = bytes(self.input_bytes[:num_frames])
self.input_bytes = self.input_bytes[num_frames:]
return data
class MyAudio:
"""PyAudio替代品,用于创建和管理音频流"""
"""PyAudio 替代品,用于创建和管理音频流"""
Stream: ClassVar[type] = MyStream
def __init__(self, buffer_size: int = GlobalStream.DEFAULT_BUFFER_SIZE) -> None:
# 初始化全局音频缓冲区
self._global_buffer = GlobalStream()
# 设置缓冲区大小
if buffer_size != GlobalStream.DEFAULT_BUFFER_SIZE:
self._global_buffer.set_buffer_size(buffer_size)
@classmethod
def create(cls):
if get_xiaoai().mode != "xiaozhi":
return MyAudio()
else:
from pyaudio import PyAudio
return PyAudio()
@classmethod
def get_input_device_index(cls, audio):
if get_xiaoai().mode != "xiaozhi":
return 0
try:
device = audio.get_default_input_device_info()
return device["index"]
except Exception:
for i in range(audio.get_device_count()):
dev = audio.get_device_info_by_index(i)
if dev["maxInputChannels"] > 0:
return i
return 0
@classmethod
def get_output_device_index(cls, audio):
if get_xiaoai().mode != "xiaozhi":
return 0
try:
device = audio.get_default_output_device_info()
return device["index"]
except Exception:
for i in range(audio.get_device_count()):
dev = audio.get_device_info_by_index(i)
if dev["maxOutputChannels"] > 0:
return i
return 0
def __init__(self) -> None:
self._is_terminated = False
def open(
@@ -261,17 +174,9 @@ class MyAudio:
output_host_api_specific_stream_info: Optional[Any] = None,
stream_callback: Optional[Callable] = None,
) -> MyStream:
"""打开一个新的音频流"""
if self._is_terminated:
raise RuntimeError("MyAudio instance has been terminated")
# 启动全局输入/输出流(如果需要)
if input:
self._global_buffer.start_input()
if output:
self._global_buffer.start_output()
# 创建并返回一个新的流实例
return MyStream(
rate=rate,
channels=channels,
@@ -283,8 +188,5 @@ class MyAudio:
)
def terminate(self) -> None:
"""终止MyAudio实例"""
if not self._is_terminated:
self._global_buffer.stop_input()
self._global_buffer.stop_output()
self._is_terminated = True
@@ -0,0 +1,183 @@
import threading
import time
import numpy as np
from config import APP_CONFIG
from xiaozhi.event import EventManager
from xiaozhi.ref import set_vad
from xiaozhi.services.audio.stream import MyAudio
from xiaozhi.services.audio.vad.silero import VAD_MODEL
from xiaozhi.services.protocols.typing import AudioConfig
from xiaozhi.utils.base import get_env
class _VAD:
"""基于WebRTC VAD的语音活动检测器,用于检测用户打断"""
def __init__(self):
set_vad(self)
config = APP_CONFIG.get("vad", {})
# 参数设置
self.sample_rate = 16000
self.frame_size = 512
self.threshold = config.get("threshold", 0.01)
self.min_speech_duration = config.get("min_speech_duration", 250)
self.min_silence_duration = config.get("min_silence_duration", 500)
# 状态变量
self.paused = True
self.thread = None
self.speech_count = 0
self.silence_count = 0
# 创建独立的PyAudio实例和流,避免与主音频流冲突
self.audio = None
self.stream = None
# 暂存的语音片段
self.temp_frames = []
self.speech_buffer = []
self.target = None # 检测目标 speech/silence
def _reset_state(self):
"""重置状态"""
self.speech_count = 0
self.silence_count = 0
self.speech_buffer = []
self.temp_frames = []
def start(self):
"""启动VAD检测器"""
if not get_env("CLI"):
return
if self.thread and self.thread.is_alive():
return
self.paused = False
# 初始化PyAudio和流
self._initialize_audio_stream()
# 启动检测线程
self.thread = threading.Thread(target=self._detection_loop, daemon=True)
self.thread.start()
def pause(self):
"""暂停VAD检测"""
if not get_env("CLI"):
return
self.paused = True
self._reset_state()
self.stream.stop_stream()
def resume(self, target: str):
"""恢复VAD检测"""
if not get_env("CLI"):
return
self.paused = False
self.target = target
self.stream.start_stream()
def _handle_speech_frame(self, frames):
"""处理语音帧"""
self.speech_count += len(frames)
self.silence_count = 0
self.speech_buffer.extend(frames)
speech_bytes = bytes(self.speech_buffer)
if (
self.target == "speech"
and self.speech_count > self.min_speech_duration * self.sample_rate / 1000
):
self.pause()
EventManager.on_speech(speech_bytes)
def _handle_silence_frame(self, frames):
"""处理静音帧"""
self.silence_count += len(frames)
self.speech_count = 0
self.speech_buffer = []
if (
self.target == "silence"
and self.silence_count > self.min_silence_duration * self.sample_rate / 1000
):
self.pause()
EventManager.on_silence()
def _detect_speech(self, frames):
"""检测是否是语音"""
try:
audio_int16 = np.frombuffer(frames, dtype=np.int16)
audio_float32 = audio_int16.astype(np.float32) / 32768.0
speech_prob = VAD_MODEL(audio_float32, 16000).item()
is_speech = speech_prob >= self.threshold
return is_speech
except Exception:
return False
def _initialize_audio_stream(self):
"""初始化独立的音频流"""
try:
# 创建 PyAudio 实例
self.audio = MyAudio.create()
# 创建输入流
self.stream = self.audio.open(
format=AudioConfig.FORMAT,
channels=1,
rate=self.sample_rate,
input=True,
frames_per_buffer=self.frame_size,
start=True,
)
return True
except Exception:
return False
def _close_audio_stream(self):
"""关闭音频流"""
try:
if self.stream:
self.stream.stop_stream()
self.stream.close()
self.stream = None
if self.audio:
self.audio.terminate()
self.audio = None
except Exception:
pass
def _detection_loop(self):
"""VAD检测主循环"""
while True:
# 如果暂停或者音频流未初始化,则跳过
if self.paused or not self.stream:
time.sleep(0.1)
continue
# 读取缓冲区音频数据
frames = self.stream.read(self.frame_size)
if len(frames) != self.frame_size * 2:
time.sleep(0.01)
continue
# 检测是否是语音
is_speech = self._detect_speech(frames)
if is_speech:
self._handle_speech_frame(frames)
else:
self._handle_silence_frame(frames)
time.sleep(0.01)
VAD = _VAD()
@@ -0,0 +1,89 @@
import numpy as np
import onnxruntime as ort
from xiaozhi.utils.file import get_model_file_path
class OnnxWrapper:
def __init__(self, path):
opts = ort.SessionOptions()
opts.inter_op_num_threads = 1
opts.intra_op_num_threads = 1
self.session = ort.InferenceSession(
path, providers=["CPUExecutionProvider"], sess_options=opts
)
self.reset_states()
self.sample_rates = [8000, 16000]
def _validate_input(self, x, sr: int):
if len(x.shape) == 1:
x = np.expand_dims(x, 0)
if len(x.shape) > 2:
raise ValueError(
f"Too many dimensions for input audio chunk {len(x.shape)}"
)
if sr != 16000 and (sr % 16000 == 0):
step = sr // 16000
x = x[:, ::step]
sr = 16000
if sr not in self.sample_rates:
raise ValueError(
f"Supported sampling rates: {self.sample_rates} (or multiply of 16000)"
)
if sr / x.shape[1] > 31.25:
raise ValueError("Input audio chunk is too short")
return x, sr
def reset_states(self, batch_size=1):
self._state = np.zeros((2, batch_size, 128), dtype=np.float32)
self._context = np.zeros(0, dtype=np.float32)
self._last_sr = 0
self._last_batch_size = 0
def __call__(self, x, sr: int):
x, sr = self._validate_input(x, sr)
num_samples = 512 if sr == 16000 else 256
if x.shape[-1] != num_samples:
raise ValueError(
f"Provided number of samples is {x.shape[-1]} (Supported values: 256 for 8000 sample rate, 512 for 16000)"
)
batch_size = x.shape[0]
context_size = 64 if sr == 16000 else 32
if not self._last_batch_size:
self.reset_states(batch_size)
if (self._last_sr) and (self._last_sr != sr):
self.reset_states(batch_size)
if (self._last_batch_size) and (self._last_batch_size != batch_size):
self.reset_states(batch_size)
if not len(self._context):
self._context = np.zeros((batch_size, context_size), dtype=np.float32)
x = np.concatenate([self._context, x], axis=1)
if sr in [8000, 16000]:
ort_inputs = {
"input": x,
"state": self._state,
"sr": np.array(sr, dtype="int64"),
}
ort_outs = self.session.run(None, ort_inputs)
out, state = ort_outs
self._state = state
else:
raise ValueError()
self._context = x[..., -context_size:]
self._last_sr = sr
self._last_batch_size = batch_size
return out
VAD_MODEL = OnnxWrapper(
path=get_model_file_path("silero_vad.onnx"),
)
@@ -1,32 +1,28 @@
from abc import ABC, abstractmethod
from typing import Optional, Callable
import logging
from typing import Callable, Optional
class BaseDisplay(ABC):
"""显示接口的抽象基类"""
def __init__(self):
self.logger = logging.getLogger(self.__class__.__name__)
self.current_volume = 70 # 默认音量
@abstractmethod
def set_callbacks(self,
press_callback: Optional[Callable] = None,
release_callback: Optional[Callable] = None,
status_callback: Optional[Callable] = None,
text_callback: Optional[Callable] = None,
emotion_callback: Optional[Callable] = None,
mode_callback: Optional[Callable] = None,
auto_callback: Optional[Callable] = None,
abort_callback: Optional[Callable] = None): # 添加打断回调参数
def set_callbacks(
self,
press_callback: Optional[Callable] = None,
release_callback: Optional[Callable] = None,
status_callback: Optional[Callable] = None,
text_callback: Optional[Callable] = None,
emotion_callback: Optional[Callable] = None,
mode_callback: Optional[Callable] = None,
auto_callback: Optional[Callable] = None,
abort_callback: Optional[Callable] = None,
):
"""设置回调函数"""
pass
@abstractmethod
def update_button_status(self, text: str):
"""更新按钮状态"""
pass
@abstractmethod
def update_status(self, status: str):
"""更新状态文本"""
@@ -50,4 +46,4 @@ class BaseDisplay(ABC):
@abstractmethod
def on_close(self):
"""关闭显示"""
pass
pass
@@ -1,10 +1,9 @@
import queue
import threading
import time
import tkinter as tk
from tkinter import ttk
import queue
import logging
import time
from typing import Optional, Callable
from typing import Callable, Optional
from xiaozhi.services.display.base_display import BaseDisplay
@@ -13,14 +12,11 @@ class GuiDisplay(BaseDisplay):
def __init__(self):
super().__init__() # 调用父类初始化
"""创建 GUI 界面"""
# 初始化日志
self.logger = logging.getLogger("Display")
# 创建主窗口
self.root = tk.Tk()
self.root.title("小爱音箱接入小智 AI 演示")
self.root.geometry("520x360")
# 在窗口底部添加作者信息
self.author_label = ttk.Label(self.root, text="作者: https://del.wang")
self.author_label.pack(side=tk.BOTTOM, pady=5)
@@ -135,8 +131,8 @@ class GuiDisplay(BaseDisplay):
# 调用回调函数
if self.button_press_callback:
self.button_press_callback()
except Exception as e:
self.logger.error(f"按钮按下回调执行失败: {e}")
except Exception:
pass
def _on_manual_button_release(self, event):
"""手动模式按钮释放事件处理"""
@@ -147,67 +143,16 @@ class GuiDisplay(BaseDisplay):
# 调用回调函数
if self.button_release_callback:
self.button_release_callback()
except Exception as e:
self.logger.error(f"按钮释放回调执行失败: {e}")
def _on_auto_button_click(self):
"""自动模式按钮点击事件处理"""
try:
if self.auto_callback:
self.auto_callback()
except Exception as e:
self.logger.error(f"自动模式按钮回调执行失败: {e}")
except Exception:
pass
def _on_abort_button_click(self):
"""打断按钮点击事件处理"""
try:
if self.abort_callback:
self.abort_callback()
except Exception as e:
self.logger.error(f"打断按钮回调执行失败: {e}")
def _on_mode_button_click(self):
"""对话模式切换按钮点击事件"""
try:
# 检查是否可以切换模式(通过回调函数询问应用程序当前状态)
if self.mode_callback:
# 如果回调函数返回False,表示当前不能切换模式
if not self.mode_callback(not self.auto_mode):
return
# 切换模式
self.auto_mode = not self.auto_mode
# 更新按钮显示
if self.auto_mode:
# 切换到自动模式
self.update_mode_button_status("自动对话")
# 隐藏手动按钮,显示自动按钮
self.update_queue.put(lambda: self._switch_to_auto_mode())
else:
# 切换到手动模式
self.update_mode_button_status("手动对话")
# 隐藏自动按钮,显示手动按钮
self.update_queue.put(lambda: self._switch_to_manual_mode())
except Exception as e:
self.logger.error(f"模式切换按钮回调执行失败: {e}")
def _switch_to_auto_mode(self):
"""切换到自动模式的UI更新"""
self.manual_btn.pack_forget() # 移除手动按钮
self.auto_btn.pack(
side=tk.LEFT, padx=10, before=self.abort_btn
) # 显示自动按钮,放在打断按钮前面
def _switch_to_manual_mode(self):
"""切换到手动模式的UI更新"""
self.auto_btn.pack_forget() # 移除自动按钮
self.manual_btn.pack(
side=tk.LEFT, padx=10, before=self.abort_btn
) # 显示手动按钮,放在打断按钮前面
except Exception:
pass
def update_status(self, status: str):
"""更新状态文本"""
@@ -245,8 +190,8 @@ class GuiDisplay(BaseDisplay):
if emotion:
self.update_emotion(emotion)
except Exception as e:
self.logger.error(f"更新失败: {e}")
except Exception:
pass
time.sleep(0.1)
threading.Thread(target=update_loop, daemon=True).start()
@@ -262,23 +207,6 @@ class GuiDisplay(BaseDisplay):
# 启动更新线程
self.start_update_threads()
# 在主线程中运行主循环
self.logger.info("开始启动GUI主循环")
self.root.mainloop()
except Exception as e:
self.logger.error(f"GUI启动失败: {e}", exc_info=True)
# 尝试回退到CLI模式
print(f"GUI启动失败: {e},请尝试使用CLI模式")
def update_mode_button_status(self, text: str):
"""更新模式按钮状态"""
self.update_queue.put(lambda: self.mode_btn.config(text=text))
def update_button_status(self, text: str):
"""更新按钮状态 - 保留此方法以满足抽象基类要求"""
# 根据当前模式更新相应的按钮
if self.auto_mode:
self.update_queue.put(lambda: self.auto_btn.config(text=text))
else:
# 在手动模式下,不通过此方法更新按钮文本
# 因为按钮文本由按下/释放事件直接控制
except Exception:
pass
@@ -0,0 +1,38 @@
import time
from typing import Callable, Optional
from xiaozhi.services.display.base_display import BaseDisplay
class NoDisplay(BaseDisplay):
def set_callbacks(
self,
press_callback: Optional[Callable] = None,
release_callback: Optional[Callable] = None,
status_callback: Optional[Callable] = None,
text_callback: Optional[Callable] = None,
emotion_callback: Optional[Callable] = None,
mode_callback: Optional[Callable] = None,
auto_callback: Optional[Callable] = None,
abort_callback: Optional[Callable] = None,
):
pass
def update_status(self, status: str):
pass
def update_text(self, text: str):
pass
def update_emotion(self, emotion: str):
pass
def start_update_threads(self):
pass
def on_close(self):
pass
def start(self):
while True:
time.sleep(1)
@@ -1,6 +1,6 @@
import json
from xiaozhi.services.protocols.typing import AbortReason, ListeningMode
from xiaozhi.services.protocols.typing import ListeningMode
class Protocol:
@@ -38,12 +38,9 @@ class Protocol:
async def send_abort_speaking(self, reason):
"""发送中止语音的消息"""
message = {"session_id": self.session_id, "type": "abort"}
if reason == AbortReason.WAKE_WORD_DETECTED:
message["reason"] = "wake_word_detected"
message = {"session_id": self.session_id, "type": reason}
await self.send_text(json.dumps(message))
async def send_start_listening(self, mode):
"""发送开始监听的消息"""
mode_map = {
@@ -69,7 +66,7 @@ class Protocol:
message = {
"session_id": self.session_id,
"type": "iot",
"descriptors": json.loads(descriptors),
"descriptors": json.loads(descriptors),
}
await self.send_text(json.dumps(message))
@@ -6,7 +6,7 @@ class ListeningMode:
class AbortReason:
"""中止原因"""
NONE = "none"
ABORT = "abort"
WAKE_WORD_DETECTED = "wake_word_detected"
class DeviceState:
@@ -20,10 +20,10 @@ class EventType:
"""事件类型"""
SCHEDULE_EVENT = "schedule_event"
AUDIO_INPUT_READY_EVENT = "audio_input_ready_event"
AUDIO_OUTPUT_READY_EVENT = "audio_output_ready_event"
class AudioConfig:
"""音频配置"""
FORMAT = 8
SAMPLE_RATE = 24000
CHANNELS = 1
FRAME_DURATION = 60 # ms
@@ -1,13 +1,10 @@
import asyncio
import json
import logging
import websockets
from xiaozhi.services.protocols.protocol import Protocol
from xiaozhi.utils.config_manager import ConfigManager
logger = logging.getLogger("WebsocketProtocol")
from xiaozhi.utils.config import ConfigManager
class WebsocketProtocol(Protocol):
@@ -66,16 +63,13 @@ class WebsocketProtocol(Protocol):
try:
await asyncio.wait_for(self.hello_received.wait(), timeout=10.0)
self.connected = True
logger.info("已连接到WebSocket服务器")
return True
except asyncio.TimeoutError:
logger.error("等待服务器hello响应超时")
if self.on_network_error:
self.on_network_error("等待响应超时")
return False
except Exception as e:
logger.error(f"WebSocket连接失败: {e}")
if self.on_network_error:
self.on_network_error(f"无法连接服务: {str(e)}")
return False
@@ -89,27 +83,21 @@ class WebsocketProtocol(Protocol):
data = json.loads(message)
msg_type = data.get("type")
if msg_type == "hello":
# 处理服务器 hello 消息
await self._handle_server_hello(data)
else:
if self.on_incoming_json:
self.on_incoming_json(data)
except json.JSONDecodeError as e:
logger.error(f"无效的JSON消息: {message}, 错误: {e}")
elif self.on_incoming_audio: # 使用 elif 更清晰
except json.JSONDecodeError:
pass
elif self.on_incoming_audio:
self.on_incoming_audio(message)
except websockets.ConnectionClosed:
logger.info("WebSocket连接已关闭")
self.connected = False
if self.on_audio_channel_closed:
# 使用 schedule 确保回调在主线程中执行
await self.on_audio_channel_closed()
except Exception as e:
logger.error(f"消息处理错误: {e}")
self.connected = False
if self.on_network_error:
# 使用 schedule 确保错误处理在主线程中执行
self.on_network_error(f"连接错误: {str(e)}")
async def send_audio(self, data: bytes):
@@ -120,7 +108,6 @@ class WebsocketProtocol(Protocol):
try:
await self.websocket.send(data)
except Exception as e:
logger.error(f"发送音频数据失败: {e}")
if self.on_network_error:
self.on_network_error(f"发送音频失败: {str(e)}")
@@ -161,7 +148,6 @@ class WebsocketProtocol(Protocol):
# 验证传输方式
transport = data.get("transport")
if not transport or transport != "websocket":
logger.error(f"不支持的传输方式: {transport}")
return
# 获取音频参数
@@ -171,13 +157,6 @@ class WebsocketProtocol(Protocol):
sample_rate = audio_params.get("sample_rate")
if sample_rate:
self.server_sample_rate = sample_rate
# 如果服务器采样率与本地不同,记录警告
if sample_rate != self.server_sample_rate:
logger.warning(
f"服务器的音频采样率 {sample_rate} "
f"与设备输出的采样率 {self.server_sample_rate} 不一致,"
"重采样后可能会失真"
)
# 设置 hello 接收事件
self.hello_received.set()
@@ -186,10 +165,7 @@ class WebsocketProtocol(Protocol):
if self.on_audio_channel_opened:
await self.on_audio_channel_opened()
logger.info("成功处理服务器 hello 消息")
except Exception as e:
logger.error(f"处理服务器 hello 消息时出错: {e}")
if self.on_network_error:
self.on_network_error(f"处理服务器响应失败: {str(e)}")
@@ -202,5 +178,5 @@ class WebsocketProtocol(Protocol):
self.connected = False
if self.on_audio_channel_closed:
await self.on_audio_channel_closed()
except Exception as e:
logger.error(f"关闭WebSocket连接失败: {e}")
except Exception:
pass
@@ -0,0 +1,186 @@
from typing import Literal
from xiaozhi.ref import get_xiaoai, set_speaker
from xiaozhi.utils.base import json_decode, json_encode
class CommandResult:
def __init__(self, stdout: str, stderr: str, exit_code: int):
self.stdout = stdout
self.stderr = stderr
self.exit_code = exit_code
class SpeakerManager:
status: Literal["playing", "paused", "idle"] = "idle"
def __init__(self):
set_speaker(self)
async def get_playing(self, sync=False):
"""获取播放状态"""
if sync:
# 同步远端最新状态
res = await self.run_shell("mphelper mute_stat")
if "1" in res.stdout:
self.status = "playing"
elif "2" in res.stdout:
self.status = "paused"
return self.status
async def set_playing(self, playing=True):
"""播放/暂停"""
command = "mphelper play" if playing else "mphelper pause"
res = await self.run_shell(command)
return '"code": 0' in res.stdout
async def play(
self,
text=None,
url=None,
buffer=None,
blocking=True,
timeout=10 * 60 * 1000,
):
"""
播放文字、音频链接、音频流
参数:
text: 文字内容
url: 音频链接
buffer: 音频流
timeout: 超时时长(毫秒),默认10分钟
blocking: 是否阻塞运行(仅对播放文字、音频链接有效)
"""
if buffer is not None:
return get_xiaoai().on_output_data(buffer)
if blocking:
command = (
f"miplayer -f '{url}'"
if url
else f"/usr/sbin/tts_play.sh '{text.replace("'", "'\\''") or '你好'}'"
)
res = await self.run_shell(command, timeout=timeout)
return res.exit_code == 0
if url:
data = json_encode({"url": url, "type": 1})
command = f"ubus call mediaplayer player_play_url '{data}'"
else:
data = json_encode({"text": text or "你好", "save": 0})
command = f"ubus call mibrain text_to_speech '{data}'"
res = await self.run_shell(command, timeout=timeout)
return '"code": 0' in res.stdout if res else False
async def wake_up(self, awake=True, silent=True):
"""
(取消)唤醒小爱
参数:
awake: 是否唤醒
silent: 是否静默唤醒
"""
if awake:
if silent:
command = 'ubus call pnshelper event_notify \'{"src":1,"event":0}\''
else:
command = 'ubus call pnshelper event_notify \'{"src":0,"event":0}\''
else:
command = """
ubus call pnshelper event_notify '{"src":3, "event":7}'
sleep 0.1
ubus call pnshelper event_notify '{"src":3, "event":8}'
"""
res = await self.run_shell(command)
return '"code": 0' in res.stdout
async def ask_xiaoai(self, text: str, silent=False):
"""
把文字指令交给原来的小爱执行
参数:
text: 文字指令
silent: 是否静默执行
"""
data = {"nlp": 1, "nlp_text": text}
if not silent:
data["tts"] = 1
command = f"ubus call mibrain ai_service '{json_encode(data)}'"
res = await self.run_shell(command)
return '"code": 0' in res.stdout
async def abort_xiaoai(self):
"""
中断原来小爱的运行
注意:重启需要大约 1-2s 的时间,在此期间无法使用小爱音箱自带的 TTS 服务
"""
res = await self.run_shell("/etc/init.d/mico_aivs_lab restart >/dev/null 2>&1")
return res.exit_code == 0
async def get_boot(self):
"""获取启动分区"""
res = await self.run_shell("echo $(fw_env -g boot_part)")
return res.stdout.strip()
async def set_boot(self, boot_part: Literal["boot0", "boot1"]):
"""设置启动分区"""
command = f"fw_env -s boot_part {boot_part} >/dev/null 2>&1 && echo $(fw_env -g boot_part)"
res = await self.run_shell(command)
return boot_part in res.stdout
async def get_device(self):
"""获取设备型号、序列号信息"""
res = await self.run_shell("echo $(micocfg_model) $(micocfg_sn)")
info = res.stdout.strip().split(" ")
return {
"model": info[0] if len(info) > 0 else "unknown",
"sn": info[1] if len(info) > 1 else "unknown",
}
async def get_mic(self):
"""获取麦克风状态"""
res = await self.run_shell("[ ! -f /tmp/mipns/mute ] && echo on || echo off")
status = "off"
if "on" in res.stdout:
status = "on"
return status
async def set_mic(self, on=True):
"""打开/关闭麦克风"""
if on:
command = (
'ubus -t1 -S call pnshelper event_notify \'{"src":3, "event":7}\' 2>&1'
)
else:
command = (
'ubus -t1 -S call pnshelper event_notify \'{"src":3, "event":8}\' 2>&1'
)
res = await self.run_shell(command)
return '"code":0' in res.stdout
async def run_shell(self, script: str, timeout=10000):
"""
执行脚本
参数:
script: 脚本内容
timeout: 超时时间(毫秒)
"""
res = "unknown"
try:
res = await get_xiaoai().run_shell(script, timeout=timeout)
data = json_decode(res)
if data:
return CommandResult(
data.get("stdout", ""),
data.get("stderr", ""),
data.get("exit_code", 0),
)
except Exception:
return CommandResult("error", res, -1)
+12
View File
@@ -1,4 +1,10 @@
import json
import os
import random
def get_env(key: str, default_value: str | None = None):
return os.environ.get(key, default_value)
def to_set(data):
@@ -7,6 +13,12 @@ def to_set(data):
return data
def pick_one(data: list):
if len(data) == 0:
return None
return data[random.randint(0, len(data) - 1)]
def json_encode(obj, pretty=False):
try:
return json.dumps(obj, ensure_ascii=False, indent=4 if pretty else None)
@@ -1,5 +1,4 @@
import json
import logging
import re
import socket
import threading
import uuid
@@ -7,9 +6,8 @@ from typing import Any, Optional
import requests
from config import XIAOZHI_CONFIG
logger = logging.getLogger("ConfigManager")
from config import APP_CONFIG
from xiaozhi.utils.file import read_file, write_file
class ConfigManager:
@@ -26,7 +24,6 @@ class ConfigManager:
def __init__(self):
"""初始化配置管理器"""
self.logger = logger
if hasattr(self, "_initialized"):
return
self._initialized = True
@@ -34,9 +31,9 @@ class ConfigManager:
# 加载配置
self._config = {
"CLIENT_ID": None,
"DEVICE_ID": None,
"DEVICE_ID": APP_CONFIG["xiaozhi"]["DEVICE_ID"],
"NETWORK": APP_CONFIG.get("xiaozhi"),
"MQTT_INFO": None,
"NETWORK": XIAOZHI_CONFIG,
}
self._initialize_client_id()
@@ -78,10 +75,22 @@ class ConfigManager:
current = current.setdefault(part, {})
current[last] = value
return True
except Exception as e:
logger.error(f"Error updating config {path}: {e}")
except Exception:
return False
def update_config_file(self, path: str, value: Any):
"""
更新 config.py 文件中的特定配置项
"""
write_file(
"config.py",
re.sub(
r'"{}"\s*:\s*"[^"]*"'.format(path),
f'"{path}": "{value}"',
read_file("config.py"),
),
)
@classmethod
def instance(cls):
"""获取配置管理器实例(线程安全)"""
@@ -111,24 +120,23 @@ class ConfigManager:
"""确保存在客户端ID"""
if not self._config["CLIENT_ID"]:
client_id = self.generate_uuid()
success = self.update_config("CLIENT_ID", client_id)
if success:
logger.info(f"Generated new CLIENT_ID: {client_id}")
else:
logger.error("Failed to save new CLIENT_ID")
self.update_config("CLIENT_ID", client_id)
def _initialize_device_id(self):
"""确保存在设备ID"""
if self._config["DEVICE_ID"]:
# 检查设备 ID 是否符合 MAC 地址格式(如 a6:85:b4:9c:09:66)
mac_pattern = re.compile(r"^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$")
if not mac_pattern.match(self._config["DEVICE_ID"]):
self._config["DEVICE_ID"] = None
if not self._config["DEVICE_ID"]:
try:
device_hash = self.get_mac_address()
success = self.update_config("DEVICE_ID", device_hash)
if success:
logger.info(f"Generated new DEVICE_ID: {device_hash}")
else:
logger.error("Failed to save new DEVICE_ID")
except Exception as e:
logger.error(f"Error generating DEVICE_ID: {e}")
self.update_config("DEVICE_ID", device_hash)
self.update_config_file("DEVICE_ID", device_hash)
except Exception:
pass
def refresh_mqtt_info(self):
"""刷新 MQTT 信息"""
@@ -140,13 +148,10 @@ class ConfigManager:
mqtt_info = self._get_ota_version()
if mqtt_info:
self.update_config("MQTT_INFO", mqtt_info)
self.logger.info("MQTT信息已成功更新")
return mqtt_info
else:
self.logger.warning("获取MQTT信息失败,使用已保存的配置")
return self.get_config("MQTT_INFO")
except Exception as e:
self.logger.error(f"初始化MQTT信息失败: {e}")
except Exception:
return self.get_config("MQTT_INFO")
def _get_ota_version(self):
@@ -200,26 +205,16 @@ class ConfigManager:
# 检查HTTP状态码
if response.status_code != 200:
self.logger.error(f"OTA服务器错误: HTTP {response.status_code}")
raise ValueError(f"OTA服务器返回错误状态码: {response.status_code}")
# 解析JSON数据
response_data = response.json()
self.logger.debug(
f"OTA服务器返回数据: {json.dumps(response_data, indent=4, ensure_ascii=False)}"
)
if "mqtt" in response_data:
self.logger.info("MQTT服务器信息已更新")
return response_data["mqtt"]
else:
self.logger.error("OTA服务器返回的数据无效: MQTT信息缺失")
raise ValueError("OTA服务器返回的数据无效,请检查服务器状态或MAC地址!")
except requests.Timeout:
self.logger.error("OTA请求超时,请检查网络或服务器状态")
raise ValueError("OTA请求超时!请稍后重试。")
except requests.RequestException as e:
self.logger.error(f"OTA请求失败: {e}")
except requests.RequestException:
raise ValueError("无法连接到OTA服务器,请检查网络连接!")
+16
View File
@@ -0,0 +1,16 @@
import os
def get_model_file_path(file_name: str):
current_dir = os.path.dirname(os.path.abspath(__file__))
return os.path.abspath(os.path.join(current_dir, "../models", file_name))
def read_file(file_path: str):
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
def write_file(file_path: str, content: str):
with open(file_path, "w", encoding="utf-8") as f:
f.write(content)
@@ -1,30 +0,0 @@
import logging
def setup_logging():
"""配置日志系统"""
# 创建根日志记录器
root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO) # 设置根日志级别
# 清除已有的处理器(避免重复添加)
if root_logger.handlers:
root_logger.handlers.clear()
# 创建控制台处理器
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
# 创建格式化器
formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
console_handler.setFormatter(formatter)
# 添加处理器到根日志记录器
root_logger.addHandler(console_handler)
# 设置特定模块的日志级别
logging.getLogger("XiaoZhi").setLevel(logging.INFO)
logging.getLogger("WebsocketProtocol").setLevel(logging.INFO)
+38 -20
View File
@@ -5,17 +5,31 @@ import threading
import numpy as np
import open_xiaoai_server
from config import APP_CONFIG
from xiaozhi.event import EventManager
from xiaozhi.ref import get_speaker, set_xiaoai
from xiaozhi.services.audio.stream import GlobalStream
from xiaozhi.services.speaker import SpeakerManager
from xiaozhi.utils.base import json_decode
ASCII_BANNER = """
▄▖ ▖▖▘ ▄▖▄▖
▌▌▛▌█▌▛▌▚▘▌▀▌▛▌▌▌▐
▙▌▙▌▙▖▌▌▌▌▌█▌▙▌▛▌▟▖
v1.0.0 by: https://del.wang
"""
class XiaoAI:
mode = "xiaoai"
sync_loop: asyncio.AbstractEventLoop = None
speaker = SpeakerManager()
async_loop: asyncio.AbstractEventLoop = None
@classmethod
def setup_mode(cls):
set_xiaoai(cls)
parser = argparse.ArgumentParser(
description="小爱音箱接入小智 AI | by: https://del.wang"
)
@@ -33,19 +47,21 @@ class XiaoAI:
@classmethod
def on_input_data(cls, data: bytes):
audio_array = np.frombuffer(data, dtype=np.uint16)
GlobalStream().add_input_data(audio_array.tobytes())
GlobalStream.input(audio_array.tobytes())
@classmethod
def on_output_data(cls, data: bytes):
async def on_output_data_async(data: bytes):
return await open_xiaoai_server.on_output_data(data)
future = on_output_data_async(data)
cls.sync_loop.run_until_complete(future)
asyncio.run_coroutine_threadsafe(
on_output_data_async(data),
cls.async_loop,
)
@classmethod
async def run_shell(cls, script: str, timeout_millis: float = 10 * 1000):
return await open_xiaoai_server.run_shell(script, timeout_millis)
async def run_shell(cls, script: str, timeout: float = 10 * 1000):
return await open_xiaoai_server.run_shell(script, timeout)
@classmethod
async def on_event(cls, event: str):
@@ -53,18 +69,16 @@ class XiaoAI:
event_data = event_json.get("data", {})
event_type = event_json.get("event")
async def on_wakeup(text, source):
before_wakeup = APP_CONFIG["wakeup"]["before_wakeup"]
wakeup = await before_wakeup(get_speaker(), text, source)
if wakeup:
EventManager.on_wakeup()
if not event_json.get("event"):
print(f"❌ Event 解析失败: {event}")
return
if event_type == "kws":
if event_data == "Started":
print("🔥 自定义唤醒词已开启")
await cls.run_shell("/usr/sbin/tts_play.sh '自定义唤醒词已开启'")
else:
keyword = event_data.get("Keyword")
print(f"🔥 自定义唤醒词: {keyword}")
elif event_type == "instruction" and event_data.get("NewLine"):
if event_type == "instruction" and event_data.get("NewLine"):
line = json_decode(event_data.get("NewLine"))
if (
line
@@ -72,15 +86,18 @@ class XiaoAI:
and line.get("header", {}).get("name") == "RecognizeResult"
):
text = line.get("payload", {}).get("results")[0].get("text")
if not text:
print("🔥 唤醒小爱同学")
if not text and not line.get("payload", {}).get("is_vad_begin"):
print(f"🔥 唤醒小爱: {line}")
EventManager.on_interrupt()
elif text and line.get("payload", {}).get("is_final"):
print(f"🔥 收到用户指令: {text}")
print(f"🔥 收到指令: {text}")
await on_wakeup(text, "xiaoai")
elif event_type == "playing":
get_speaker().status = event_data.lower()
@classmethod
def __init_background_event_loop(cls):
def run_event_loop():
cls.sync_loop = asyncio.new_event_loop()
cls.async_loop = asyncio.new_event_loop()
asyncio.set_event_loop(cls.async_loop)
cls.async_loop.run_forever()
@@ -97,8 +114,9 @@ class XiaoAI:
@classmethod
async def init_xiaoai(cls):
GlobalStream().on_output_data = cls.on_output_data
GlobalStream.on_output_data = cls.on_output_data
open_xiaoai_server.register_fn("on_input_data", cls.on_input_data)
open_xiaoai_server.register_fn("on_event", cls.__on_event)
cls.__init_background_event_loop()
print(ASCII_BANNER)
await open_xiaoai_server.start_server()
+91 -315
View File
@@ -1,11 +1,13 @@
import asyncio
import json
import logging
import re
import threading
import time
from xiaozhi.services.display import gui_display
from xiaozhi.event import EventManager
from xiaozhi.ref import set_xiaozhi
from xiaozhi.services.audio.kws import KWS
from xiaozhi.services.audio.vad import VAD
from xiaozhi.services.protocols.typing import (
AbortReason,
AudioConfig,
@@ -14,12 +16,10 @@ from xiaozhi.services.protocols.typing import (
ListeningMode,
)
from xiaozhi.services.protocols.websocket_protocol import WebsocketProtocol
from xiaozhi.utils.config_manager import ConfigManager
from xiaozhi.utils.base import get_env
from xiaozhi.utils.config import ConfigManager
from xiaozhi.xiaoai import XiaoAI
# 配置日志
logger = logging.getLogger("XiaoZhi")
class XiaoZhi:
"""智能音箱应用程序主类"""
@@ -46,8 +46,6 @@ class XiaoZhi:
# 状态变量
self.device_state = DeviceState.IDLE
self.voice_detected = False
self.keep_listening = False
self.aborted = False
self.current_text = ""
self.current_emotion = "neutral"
@@ -73,11 +71,11 @@ class XiaoZhi:
self.events = {
EventType.SCHEDULE_EVENT: threading.Event(),
EventType.AUDIO_INPUT_READY_EVENT: threading.Event(),
EventType.AUDIO_OUTPUT_READY_EVENT: threading.Event(),
}
# 创建显示界面
self.display = None
set_xiaozhi(self)
def run(self):
self.protocol = WebsocketProtocol()
@@ -99,6 +97,9 @@ class XiaoZhi:
main_loop_thread.daemon = True
main_loop_thread.start()
VAD.start()
KWS.start()
# 启动 GUI
self._initialize_display()
self.display.start()
@@ -110,10 +111,6 @@ class XiaoZhi:
async def _initialize_without_connect(self):
"""初始化应用程序组件(不建立连接)"""
logger.info("正在初始化应用程序...")
# 设置设备状态为待命
self.set_device_state(DeviceState.IDLE)
# 初始化音频编解码器
self._initialize_audio()
@@ -125,7 +122,8 @@ class XiaoZhi:
self.protocol.on_audio_channel_opened = self._on_audio_channel_opened
self.protocol.on_audio_channel_closed = self._on_audio_channel_closed
logger.info("应用程序初始化完成")
# 设置设备状态为待命
self.set_device_state(DeviceState.IDLE)
def _initialize_audio(self):
"""初始化音频设备和编解码器"""
@@ -133,14 +131,19 @@ class XiaoZhi:
from xiaozhi.services.audio.codec import AudioCodec
self.audio_codec = AudioCodec()
logger.info("音频编解码器初始化成功")
except Exception as e:
logger.error(f"初始化音频设备失败: {e}")
self.alert("错误", f"初始化音频设备失败: {e}")
def _initialize_display(self):
"""初始化显示界面"""
self.display = gui_display.GuiDisplay()
if get_env("CLI"):
from xiaozhi.services.display import no_display
self.display = no_display.NoDisplay()
else:
from xiaozhi.services.display import gui_display
self.display = gui_display.GuiDisplay()
# 设置回调函数
self.display.set_callbacks(
@@ -156,7 +159,6 @@ class XiaoZhi:
def _main_loop(self):
"""应用程序主循环"""
logger.info("主循环已启动")
self.running = True
while self.running:
@@ -167,12 +169,9 @@ class XiaoZhi:
if event_type == EventType.AUDIO_INPUT_READY_EVENT:
self._handle_input_audio()
elif event_type == EventType.AUDIO_OUTPUT_READY_EVENT:
self._handle_output_audio()
elif event_type == EventType.SCHEDULE_EVENT:
self._process_scheduled_tasks()
# 短暂休眠以避免CPU占用过高
time.sleep(0.01)
def _process_scheduled_tasks(self):
@@ -184,8 +183,8 @@ class XiaoZhi:
for task in tasks:
try:
task()
except Exception as e:
logger.error(f"执行调度任务时出错: {e}")
except Exception:
pass
def schedule(self, callback):
"""调度任务到主循环"""
@@ -209,19 +208,10 @@ class XiaoZhi:
self.protocol.send_audio(encoded_data), self.loop
)
def _handle_output_audio(self):
"""处理音频输出"""
if self.device_state != DeviceState.SPEAKING:
return
self.audio_codec.play_audio()
def _on_network_error(self, message):
"""网络错误回调"""
self.keep_listening = False
self.set_device_state(DeviceState.IDLE)
if self.device_state != DeviceState.CONNECTING:
logger.info("检测到连接断开")
self.set_device_state(DeviceState.IDLE)
# 关闭现有连接
@@ -233,7 +223,6 @@ class XiaoZhi:
def _attempt_reconnect(self):
"""尝试重新连接服务器"""
if self.device_state != DeviceState.CONNECTING:
logger.info("检测到连接断开,尝试重新连接...")
self.set_device_state(DeviceState.CONNECTING)
# 关闭现有连接
@@ -264,16 +253,13 @@ class XiaoZhi:
max_retries = 3
while retry_count < max_retries:
logger.info(f"尝试重新连接 (尝试 {retry_count + 1}/{max_retries})...")
if await self.protocol.connect():
logger.info("重新连接成功")
self.set_device_state(DeviceState.IDLE)
return True
retry_count += 1
await asyncio.sleep(2) # 等待2秒后重试
logger.error(f"重新连接失败,已尝试 {max_retries}")
self.schedule(lambda: self.alert("连接错误", "无法重新连接到服务器"))
self.set_device_state(DeviceState.IDLE)
return False
@@ -282,7 +268,6 @@ class XiaoZhi:
"""接收音频数据回调"""
if self.device_state == DeviceState.SPEAKING:
self.audio_codec.write_audio(data)
self.events[EventType.AUDIO_OUTPUT_READY_EVENT].set()
def _on_incoming_json(self, json_data):
"""接收JSON数据回调"""
@@ -304,48 +289,33 @@ class XiaoZhi:
self._handle_stt_message(data)
elif msg_type == "llm":
self._handle_llm_message(data)
else:
logger.warning(f"收到未知类型的消息: {msg_type}")
except Exception as e:
logger.error(f"处理JSON消息时出错: {e}")
except Exception:
pass
def _handle_tts_message(self, data):
"""处理TTS消息"""
state = data.get("state", "")
if state == "start":
EventManager.on_tts_start()
self.schedule(lambda: self._handle_tts_start())
elif state == "stop":
EventManager.on_tts_end()
self.schedule(lambda: self._handle_tts_stop())
elif state == "sentence_start":
text = data.get("text", "")
if text:
logger.info(f"<< {text}")
print(f"🤖 小智:{text}")
need_verification_code = re.search(r"验证码.*\d+", text)
verification_tips = (
"\n🔥 注意:绑定成功后,需要重新运行本应用才会生效"
if need_verification_code
else ""
)
if need_verification_code:
logger.info(verification_tips)
self.schedule(
lambda: self.set_chat_message(
"assistant",
text + verification_tips,
verification_code = re.findall(r"验证码.*\d+", text)
if verification_code:
self.config.update_config_file(
"VERIFICATION_CODE", verification_code[0]
)
)
self.schedule(lambda: self.set_chat_message("assistant", text))
def _handle_tts_start(self):
"""处理TTS开始事件"""
self.aborted = False
# 清空可能存在的旧音频数据
self.audio_codec.clear_audio_queue()
if (
self.device_state == DeviceState.IDLE
or self.device_state == DeviceState.LISTENING
@@ -354,30 +324,13 @@ class XiaoZhi:
def _handle_tts_stop(self):
"""处理TTS停止事件"""
if self.device_state == DeviceState.SPEAKING:
# 给音频播放一个缓冲时间,确保所有音频都播放完毕
def delayed_state_change():
# 等待音频队列清空
self.audio_codec.wait_for_audio_complete()
# 状态转换
if self.keep_listening:
asyncio.run_coroutine_threadsafe(
self.protocol.send_start_listening(ListeningMode.AUTO_STOP),
self.loop,
)
self.set_device_state(DeviceState.LISTENING)
else:
self.set_device_state(DeviceState.IDLE)
# 安排延迟执行
threading.Thread(target=delayed_state_change, daemon=True).start()
pass
def _handle_stt_message(self, data):
"""处理STT消息"""
text = data.get("text", "")
if text:
logger.info(f">> {text}")
print(f"💬 我说:{text}")
self.schedule(lambda: self.set_chat_message("user", text))
def _handle_llm_message(self, data):
@@ -388,26 +341,19 @@ class XiaoZhi:
async def _on_audio_channel_opened(self):
"""音频通道打开回调"""
logger.info("音频通道已打开")
self.schedule(lambda: self._start_audio_streams())
def _start_audio_streams(self):
"""启动音频流"""
try:
# 确保流已关闭后再重新打开
if (
self.audio_codec.input_stream
and self.audio_codec.input_stream.is_active()
):
if self.audio_codec.input_stream.is_active():
self.audio_codec.input_stream.stop_stream()
# 重新打开流
self.audio_codec.input_stream.start_stream()
if (
self.audio_codec.output_stream
and self.audio_codec.output_stream.is_active()
):
if self.audio_codec.output_stream.is_active():
self.audio_codec.output_stream.stop_stream()
# 重新打开流
@@ -417,141 +363,65 @@ class XiaoZhi:
threading.Thread(
target=self._audio_input_event_trigger, daemon=True
).start()
threading.Thread(
target=self._audio_output_event_trigger, daemon=True
).start()
logger.info("音频流已启动")
except Exception as e:
logger.error(f"启动音频流失败: {e}")
except Exception:
pass
def _audio_input_event_trigger(self):
"""音频输入事件触发器"""
while self.running:
try:
if (
self.audio_codec.input_stream
and self.audio_codec.input_stream.is_active()
):
if self.audio_codec.input_stream.is_active():
self.events[EventType.AUDIO_INPUT_READY_EVENT].set()
except OSError as e:
logger.error(f"音频输入流错误: {e}")
# 如果流已关闭,尝试重新打开或者退出循环
if "Stream not open" in str(e):
break
except Exception as e:
logger.error(f"音频输入事件触发器错误: {e}")
except Exception:
pass
time.sleep(AudioConfig.FRAME_DURATION / 1000) # 按帧时长触发
def _audio_output_event_trigger(self):
"""音频输出事件触发器"""
while (
self.running
and self.audio_codec.output_stream
and self.audio_codec.output_stream.is_active()
):
# 当队列中有数据时才触发事件
if (
not self.audio_codec.audio_decode_queue.empty()
): # 修改为使用 audio_codec 的队列
self.events[EventType.AUDIO_OUTPUT_READY_EVENT].set()
time.sleep(0.02) # 稍微延长检查间隔
async def _on_audio_channel_closed(self):
"""音频通道关闭回调"""
logger.info("音频通道已关闭")
self.set_device_state(DeviceState.IDLE)
self.keep_listening = False
self.schedule(lambda: self._stop_audio_streams())
def _stop_audio_streams(self):
"""停止音频流"""
try:
if (
self.audio_codec.input_stream
and self.audio_codec.input_stream.is_active()
):
self.audio_codec.input_stream.stop_stream()
if (
self.audio_codec.output_stream
and self.audio_codec.output_stream.is_active()
):
self.audio_codec.output_stream.stop_stream()
logger.info("音频流已停止")
except Exception as e:
logger.error(f"停止音频流失败: {e}")
self.audio_codec.stop_streams()
def set_device_state(self, state):
"""设置设备状态"""
if self.device_state == state:
return
old_state = self.device_state
# 如果从 SPEAKING 状态切换出去,确保音频播放完成
if old_state == DeviceState.SPEAKING:
self.audio_codec.wait_for_audio_complete()
self.device_state = state
logger.info(f"状态变更: {old_state} -> {state}")
# 根据状态执行相应操作
VAD.pause() # 停用 VAD
self.audio_codec.stop_streams() # 停用输入输出流
if state == DeviceState.IDLE:
self.display.update_status("待命")
self.display.update_emotion("😶")
if (
self.audio_codec.output_stream
and self.audio_codec.output_stream.is_active()
):
try:
self.audio_codec.output_stream.stop_stream()
except Exception as e:
logger.warning(f"停止输出流时出错: {e}")
elif state == DeviceState.CONNECTING:
self.display.update_status("连接中...")
elif state == DeviceState.LISTENING:
self.display.update_status("聆听中...")
self.display.update_emotion("🙂")
if (
self.audio_codec.input_stream
and not self.audio_codec.input_stream.is_active()
):
try:
self.audio_codec.input_stream.start_stream()
except Exception as e:
logger.warning(f"启动输入流时出错: {e}")
# 使用 AudioCodec 类中的方法重新初始化
self.audio_codec._reinitialize_input_stream()
# 停止输出流
if self.audio_codec.output_stream.is_active():
self.audio_codec.output_stream.stop_stream()
# 打开输入流
if not self.audio_codec.input_stream.is_active():
self.audio_codec.input_stream.start_stream()
elif state == DeviceState.SPEAKING:
self.display.update_status("说话中...")
# 确保输出流处于活跃状态
if self.audio_codec.output_stream:
if not self.audio_codec.output_stream.is_active():
try:
self.audio_codec.output_stream.start_stream()
except Exception as e:
logger.warning(f"启动输出流时出错: {e}")
# 使用 AudioCodec 类中的方法重新初始化
self.audio_codec._reinitialize_output_stream()
# 停止输入流
if (
self.audio_codec.input_stream
and self.audio_codec.input_stream.is_active()
):
try:
self.audio_codec.input_stream.stop_stream()
except Exception as e:
logger.warning(f"停止输入流时出错: {e}")
if self.audio_codec.input_stream.is_active():
self.audio_codec.input_stream.stop_stream()
# 打开输出流
if not self.audio_codec.output_stream.is_active():
self.audio_codec.output_stream.start_stream()
# 通知状态变化
for callback in self.on_state_changed_callbacks:
try:
callback(state)
except Exception as e:
logger.error(f"执行状态变化回调时出错: {e}")
except Exception:
pass
def _get_status_text(self):
"""获取当前状态文本"""
@@ -615,105 +485,33 @@ class XiaoZhi:
def _start_listening_impl(self):
"""开始监听的实现"""
if not self.protocol:
logger.error("协议未初始化")
return
self.keep_listening = False
self.set_device_state(DeviceState.IDLE)
asyncio.run_coroutine_threadsafe(
self.protocol.send_abort_speaking(AbortReason.ABORT),
self.loop,
)
if self.device_state == DeviceState.IDLE:
# 尝试打开音频通道
if not self.protocol.is_audio_channel_opened():
self.set_device_state(DeviceState.CONNECTING) # 设置设备状态为连接中
try:
# 等待异步操作完成
future = asyncio.run_coroutine_threadsafe(
self.protocol.open_audio_channel(), self.loop
)
# 等待操作完成并获取结果
assert future.result(timeout=10.0) # 添加超时时间
except Exception as e:
self.alert("错误", f"打开音频通道失败: {str(e)}")
self.set_device_state(DeviceState.IDLE)
return
# 尝试打开音频通道
if not self.protocol.is_audio_channel_opened():
try:
# 等待异步操作完成
future = asyncio.run_coroutine_threadsafe(
self.protocol.open_audio_channel(), self.loop
)
# 等待操作完成并获取结果
success = future.result(timeout=10.0) # 添加超时时间
if not success:
self.alert("错误", "打开音频通道失败") # 弹出错误提示
self.set_device_state(DeviceState.IDLE) # 设置设备状态为空闲
return
except Exception as e:
logger.error(f"打开音频通道时发生错误: {e}")
self.alert("错误", f"打开音频通道失败: {str(e)}")
self.set_device_state(DeviceState.IDLE)
return
asyncio.run_coroutine_threadsafe(
self.protocol.send_start_listening(ListeningMode.MANUAL), self.loop
)
self.set_device_state(DeviceState.LISTENING) # 设置设备状态为监听中
elif self.device_state == DeviceState.SPEAKING:
if not self.aborted:
self.abort_speaking(AbortReason.WAKE_WORD_DETECTED)
async def _open_audio_channel_and_start_manual_listening(self):
"""打开音频通道并开始手动监听"""
if not await self.protocol.open_audio_channel():
self.set_device_state(DeviceState.IDLE)
self.alert("错误", "打开音频通道失败")
return
await self.protocol.send_start_listening(ListeningMode.MANUAL)
self.set_device_state(DeviceState.LISTENING)
def toggle_chat_state(self):
"""切换聊天状态"""
self.schedule(self._toggle_chat_state_impl)
def _toggle_chat_state_impl(self):
"""切换聊天状态的具体实现"""
# 检查协议是否已初始化
if not self.protocol:
logger.error("协议未初始化")
return
# 如果设备当前处于空闲状态,尝试连接并开始监听
if self.device_state == DeviceState.IDLE:
self.set_device_state(DeviceState.CONNECTING) # 设置设备状态为连接中
# 尝试打开音频通道
if not self.protocol.is_audio_channel_opened():
try:
# 等待异步操作完成
future = asyncio.run_coroutine_threadsafe(
self.protocol.open_audio_channel(), self.loop
)
# 等待操作完成并获取结果
success = future.result(timeout=10.0) # 添加超时时间
if not success:
self.alert("错误", "打开音频通道失败") # 弹出错误提示
self.set_device_state(DeviceState.IDLE) # 设置设备状态为空闲
return
except Exception as e:
logger.error(f"打开音频通道时发生错误: {e}")
self.alert("错误", f"打开音频通道失败: {str(e)}")
self.set_device_state(DeviceState.IDLE)
return
self.keep_listening = True # 开始监听
# 启动自动停止的监听模式
asyncio.run_coroutine_threadsafe(
self.protocol.send_start_listening(ListeningMode.AUTO_STOP), self.loop
)
self.set_device_state(DeviceState.LISTENING) # 设置设备状态为监听中
# 如果设备正在说话,停止当前说话
elif self.device_state == DeviceState.SPEAKING:
self.abort_speaking(AbortReason.NONE) # 中止说话
# 如果设备正在监听,关闭音频通道
elif self.device_state == DeviceState.LISTENING:
asyncio.run_coroutine_threadsafe(
self.protocol.close_audio_channel(), self.loop
)
asyncio.run_coroutine_threadsafe(
self.protocol.send_start_listening(ListeningMode.MANUAL), self.loop
)
self.set_device_state(DeviceState.LISTENING) # 设置设备状态为监听中
def stop_listening(self):
"""停止监听"""
@@ -721,35 +519,19 @@ class XiaoZhi:
def _stop_listening_impl(self):
"""停止监听的实现"""
if self.device_state == DeviceState.LISTENING:
asyncio.run_coroutine_threadsafe(
self.protocol.send_stop_listening(), self.loop
)
self.set_device_state(DeviceState.IDLE)
self.set_device_state(DeviceState.IDLE)
asyncio.run_coroutine_threadsafe(self.protocol.send_stop_listening(), self.loop)
def abort_speaking(self, reason):
"""中止语音输出"""
logger.info(f"中止语音输出,原因: {reason}")
self.aborted = True
asyncio.run_coroutine_threadsafe(
self.protocol.send_abort_speaking(reason), self.loop
)
self.set_device_state(DeviceState.IDLE)
# 添加此代码:当用户主动打断时自动进入录音模式
if reason == AbortReason.WAKE_WORD_DETECTED and self.keep_listening:
# 短暂延迟确保abort命令被处理
def start_listening_after_abort():
time.sleep(0.2) # 短暂延迟
self.set_device_state(DeviceState.IDLE)
self.schedule(lambda: self.toggle_chat_state())
threading.Thread(target=start_listening_after_abort, daemon=True).start()
asyncio.run_coroutine_threadsafe(
self.protocol.send_abort_speaking(AbortReason.ABORT),
self.loop,
)
def alert(self, title, message):
"""显示警告信息"""
logger.warning(f"警告: {title}, {message}")
# 在GUI上显示警告
if self.display:
self.display.update_text(f"{title}: {message}")
@@ -759,7 +541,6 @@ class XiaoZhi:
def shutdown(self):
"""关闭应用程序"""
logger.info("正在关闭应用程序...")
self.running = False
# 关闭音频编解码器
@@ -780,15 +561,10 @@ class XiaoZhi:
if self.loop_thread and self.loop_thread.is_alive():
self.loop_thread.join(timeout=1.0)
logger.info("应用程序已关闭")
def toggle_chat_state(self):
"""切换聊天状态"""
pass
def _on_mode_changed(self, auto_mode):
"""处理对话模式变更"""
# 只有在IDLE状态下才允许切换模式
if self.device_state != DeviceState.IDLE:
self.alert("提示", "只有在待命状态下才能切换对话模式")
return False
self.keep_listening = auto_mode
logger.info(f"对话模式已切换为: {'自动' if auto_mode else '手动'}")
return True
pass