diff --git a/.dockerignore b/.dockerignore index 6e5ed24..086ed34 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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 diff --git a/.gitignore b/.gitignore index c6d0b4b..fac29de 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ yarn-error.log* .venv .mypy_cache __pycache__ +*.egg-info # Misc .DS_Store diff --git a/examples/gemini/README.md b/examples/gemini/README.md index 03e7d5d..6420607 100644 --- a/examples/gemini/README.md +++ b/examples/gemini/README.md @@ -19,7 +19,7 @@ ```bash # 安装 Python 依赖 -uv sync +uv sync --locked # 编译运行 uv run main.py diff --git a/examples/kws/README.md b/examples/kws/README.md index eb8175b..5f0991b 100644 --- a/examples/kws/README.md +++ b/examples/kws/README.md @@ -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:是否支持说话人(声纹)识别? diff --git a/examples/migpt/migpt/speaker.ts b/examples/migpt/migpt/speaker.ts index 93d06aa..7d17433 100644 --- a/examples/migpt/migpt/speaker.ts +++ b/examples/migpt/migpt/speaker.ts @@ -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}'` diff --git a/examples/xiaozhi/.vscode/settings.json b/examples/xiaozhi/.vscode/settings.json index c139dad..ab872ba 100644 --- a/examples/xiaozhi/.vscode/settings.json +++ b/examples/xiaozhi/.vscode/settings.json @@ -17,7 +17,6 @@ "**/node_modules": true, "**/__pycache__": true, "*.egg-info": true, - ".venv": true, ".mypy_cache": true } } diff --git a/examples/xiaozhi/Dockerfile b/examples/xiaozhi/Dockerfile new file mode 100644 index 0000000..9309f11 --- /dev/null +++ b/examples/xiaozhi/Dockerfile @@ -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"] diff --git a/examples/xiaozhi/README.md b/examples/xiaozhi/README.md index 2327bb6..4542ed4 100644 --- a/examples/xiaozhi/README.md +++ b/examples/xiaozhi/README.md @@ -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 +``` + +### 编译运行 为了能够正常编译运行该项目,你需要安装以下依赖环境/工具: - uv:https://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) 项目,特此鸣谢。 diff --git a/examples/xiaozhi/boot.sh b/examples/xiaozhi/boot.sh deleted file mode 100644 index 517a0ef..0000000 --- a/examples/xiaozhi/boot.sh +++ /dev/null @@ -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" diff --git a/examples/xiaozhi/config.py b/examples/xiaozhi/config.py index 86c4177..1ccfd3a 100644 --- a/examples/xiaozhi/config.py +++ b/examples/xiaozhi/config.py @@ -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": "首次登陆时,验证码会在这里更新", + }, } diff --git a/examples/xiaozhi/init.sh b/examples/xiaozhi/init.sh deleted file mode 100644 index dc755e0..0000000 --- a/examples/xiaozhi/init.sh +++ /dev/null @@ -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" diff --git a/examples/xiaozhi/main.py b/examples/xiaozhi/main.py index 39b6f9c..e978b21 100644 --- a/examples/xiaozhi/main.py +++ b/examples/xiaozhi/main.py @@ -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()) diff --git a/examples/xiaozhi/pyproject.toml b/examples/xiaozhi/pyproject.toml index 5a8bb42..ebac5a2 100644 --- a/examples/xiaozhi/pyproject.toml +++ b/examples/xiaozhi/pyproject.toml @@ -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", ] diff --git a/examples/xiaozhi/uv.lock b/examples/xiaozhi/uv.lock index a9b1aa9..3cbb937 100644 --- a/examples/xiaozhi/uv.lock +++ b/examples/xiaozhi/uv.lock @@ -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" diff --git a/examples/xiaozhi/xiaozhi/event.py b/examples/xiaozhi/xiaozhi/event.py new file mode 100644 index 0000000..634a13c --- /dev/null +++ b/examples/xiaozhi/xiaozhi/event.py @@ -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() diff --git a/examples/xiaozhi/xiaozhi/models/keywords.txt b/examples/xiaozhi/xiaozhi/models/keywords.txt new file mode 100644 index 0000000..ab93a7e --- /dev/null +++ b/examples/xiaozhi/xiaozhi/models/keywords.txt @@ -0,0 +1,7 @@ +天 猫 精 灵 @天猫精灵 +小 度 小 度 @小度小度 +豆 包 豆 包 @豆包豆包 +你 好 小 智 @你好小智 +你 好 小 爱 @你好小爱 +▁HI ▁S I RI +▁HE Y ▁S I RI diff --git a/examples/xiaozhi/xiaozhi/ref.py b/examples/xiaozhi/xiaozhi/ref.py new file mode 100644 index 0000000..8f4e088 --- /dev/null +++ b/examples/xiaozhi/xiaozhi/ref.py @@ -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") diff --git a/examples/xiaozhi/xiaozhi/services/audio/codec.py b/examples/xiaozhi/xiaozhi/services/audio/codec.py index 0931489..de611d8 100644 --- a/examples/xiaozhi/xiaozhi/services/audio/codec.py +++ b/examples/xiaozhi/xiaozhi/services/audio/codec.py @@ -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() diff --git a/examples/xiaozhi/xiaozhi/services/audio/kws/__init__.py b/examples/xiaozhi/xiaozhi/services/audio/kws/__init__.py new file mode 100644 index 0000000..5003d20 --- /dev/null +++ b/examples/xiaozhi/xiaozhi/services/audio/kws/__init__.py @@ -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() diff --git a/examples/xiaozhi/xiaozhi/services/audio/kws/keywords.py b/examples/xiaozhi/xiaozhi/services/audio/kws/keywords.py new file mode 100644 index 0000000..a1d6f2b --- /dev/null +++ b/examples/xiaozhi/xiaozhi/services/audio/kws/keywords.py @@ -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() diff --git a/examples/xiaozhi/xiaozhi/services/audio/kws/sherpa.py b/examples/xiaozhi/xiaozhi/services/audio/kws/sherpa.py new file mode 100644 index 0000000..667c22a --- /dev/null +++ b/examples/xiaozhi/xiaozhi/services/audio/kws/sherpa.py @@ -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() diff --git a/examples/xiaozhi/xiaozhi/services/audio/opus.dll b/examples/xiaozhi/xiaozhi/services/audio/opus.dll deleted file mode 100644 index 65e51b1..0000000 Binary files a/examples/xiaozhi/xiaozhi/services/audio/opus.dll and /dev/null differ diff --git a/examples/xiaozhi/xiaozhi/services/audio/setup.py b/examples/xiaozhi/xiaozhi/services/audio/setup.py deleted file mode 100644 index 21b4593..0000000 --- a/examples/xiaozhi/xiaozhi/services/audio/setup.py +++ /dev/null @@ -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) diff --git a/examples/xiaozhi/xiaozhi/services/audio/stream.py b/examples/xiaozhi/xiaozhi/services/audio/stream.py index d5d042f..a17f651 100644 --- a/examples/xiaozhi/xiaozhi/services/audio/stream.py +++ b/examples/xiaozhi/xiaozhi/services/audio/stream.py @@ -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 diff --git a/examples/xiaozhi/xiaozhi/services/audio/vad/__init__.py b/examples/xiaozhi/xiaozhi/services/audio/vad/__init__.py new file mode 100644 index 0000000..f74747c --- /dev/null +++ b/examples/xiaozhi/xiaozhi/services/audio/vad/__init__.py @@ -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() diff --git a/examples/xiaozhi/xiaozhi/services/audio/vad/silero.py b/examples/xiaozhi/xiaozhi/services/audio/vad/silero.py new file mode 100644 index 0000000..6342f2c --- /dev/null +++ b/examples/xiaozhi/xiaozhi/services/audio/vad/silero.py @@ -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"), +) diff --git a/examples/xiaozhi/xiaozhi/services/display/base_display.py b/examples/xiaozhi/xiaozhi/services/display/base_display.py index 0640b0d..5e197c9 100644 --- a/examples/xiaozhi/xiaozhi/services/display/base_display.py +++ b/examples/xiaozhi/xiaozhi/services/display/base_display.py @@ -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 \ No newline at end of file + pass diff --git a/examples/xiaozhi/xiaozhi/services/display/gui_display.py b/examples/xiaozhi/xiaozhi/services/display/gui_display.py index 9a492ac..fd8328a 100644 --- a/examples/xiaozhi/xiaozhi/services/display/gui_display.py +++ b/examples/xiaozhi/xiaozhi/services/display/gui_display.py @@ -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 diff --git a/examples/xiaozhi/xiaozhi/services/display/no_display.py b/examples/xiaozhi/xiaozhi/services/display/no_display.py new file mode 100644 index 0000000..eb5cf7b --- /dev/null +++ b/examples/xiaozhi/xiaozhi/services/display/no_display.py @@ -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) diff --git a/examples/xiaozhi/xiaozhi/services/protocols/protocol.py b/examples/xiaozhi/xiaozhi/services/protocols/protocol.py index a6c70f2..56a6dc9 100644 --- a/examples/xiaozhi/xiaozhi/services/protocols/protocol.py +++ b/examples/xiaozhi/xiaozhi/services/protocols/protocol.py @@ -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)) diff --git a/examples/xiaozhi/xiaozhi/services/protocols/typing.py b/examples/xiaozhi/xiaozhi/services/protocols/typing.py index f5b13db..63de7f3 100644 --- a/examples/xiaozhi/xiaozhi/services/protocols/typing.py +++ b/examples/xiaozhi/xiaozhi/services/protocols/typing.py @@ -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 diff --git a/examples/xiaozhi/xiaozhi/services/protocols/websocket_protocol.py b/examples/xiaozhi/xiaozhi/services/protocols/websocket_protocol.py index 037cd10..7c567ec 100644 --- a/examples/xiaozhi/xiaozhi/services/protocols/websocket_protocol.py +++ b/examples/xiaozhi/xiaozhi/services/protocols/websocket_protocol.py @@ -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 diff --git a/examples/xiaozhi/xiaozhi/services/speaker.py b/examples/xiaozhi/xiaozhi/services/speaker.py new file mode 100644 index 0000000..a17a616 --- /dev/null +++ b/examples/xiaozhi/xiaozhi/services/speaker.py @@ -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) diff --git a/examples/xiaozhi/xiaozhi/utils/base.py b/examples/xiaozhi/xiaozhi/utils/base.py index b2e56d5..b8ee714 100644 --- a/examples/xiaozhi/xiaozhi/utils/base.py +++ b/examples/xiaozhi/xiaozhi/utils/base.py @@ -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) diff --git a/examples/xiaozhi/xiaozhi/utils/config_manager.py b/examples/xiaozhi/xiaozhi/utils/config.py similarity index 76% rename from examples/xiaozhi/xiaozhi/utils/config_manager.py rename to examples/xiaozhi/xiaozhi/utils/config.py index 2358a22..39b6db4 100644 --- a/examples/xiaozhi/xiaozhi/utils/config_manager.py +++ b/examples/xiaozhi/xiaozhi/utils/config.py @@ -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服务器,请检查网络连接!") diff --git a/examples/xiaozhi/xiaozhi/utils/file.py b/examples/xiaozhi/xiaozhi/utils/file.py new file mode 100644 index 0000000..54b9a4a --- /dev/null +++ b/examples/xiaozhi/xiaozhi/utils/file.py @@ -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) diff --git a/examples/xiaozhi/xiaozhi/utils/logging_config.py b/examples/xiaozhi/xiaozhi/utils/logging_config.py deleted file mode 100644 index 6ce4475..0000000 --- a/examples/xiaozhi/xiaozhi/utils/logging_config.py +++ /dev/null @@ -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) diff --git a/examples/xiaozhi/xiaozhi/xiaoai.py b/examples/xiaozhi/xiaozhi/xiaoai.py index 93a0bb0..cbdeae8 100644 --- a/examples/xiaozhi/xiaozhi/xiaoai.py +++ b/examples/xiaozhi/xiaozhi/xiaoai.py @@ -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() diff --git a/examples/xiaozhi/xiaozhi/xiaozhi.py b/examples/xiaozhi/xiaozhi/xiaozhi.py index cc9f649..abfaf63 100644 --- a/examples/xiaozhi/xiaozhi/xiaozhi.py +++ b/examples/xiaozhi/xiaozhi/xiaozhi.py @@ -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