feat(xiaozhi): 支持在 python 端监听 event 和 runShell

This commit is contained in:
Del Wang
2025-05-11 20:47:02 +08:00
parent e687280ab1
commit 59c3280938
10 changed files with 198 additions and 21 deletions
+25
View File
@@ -0,0 +1,25 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Python: App",
"type": "debugpy",
"request": "launch",
"program": "${workspaceFolder}/main.py",
"justMyCode": false,
"console": "internalConsole",
"internalConsoleOptions": "openOnSessionStart",
"envFile": "${workspaceFolder}/.env"
},
{
"name": "Python: Current",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"justMyCode": false,
"console": "internalConsole",
"internalConsoleOptions": "openOnSessionStart",
"envFile": "${workspaceFolder}/.env"
}
]
}
+25
View File
@@ -0,0 +1,25 @@
#! /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"
+25
View File
@@ -0,0 +1,25 @@
#! /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"
+2 -2
View File
@@ -4,7 +4,7 @@ import sys
from xiaozhi.services.audio.setup import setup_opus from xiaozhi.services.audio.setup import setup_opus
from xiaozhi.utils.logging_config import setup_logging from xiaozhi.utils.logging_config import setup_logging
from xiaozhi.xiaoai import XiaoAi from xiaozhi.xiaoai import XiaoAI
from xiaozhi.xiaozhi import XiaoZhi from xiaozhi.xiaozhi import XiaoZhi
logger = logging.getLogger("Main") logger = logging.getLogger("Main")
@@ -26,7 +26,7 @@ def setup_graceful_shutdown():
if __name__ == "__main__": if __name__ == "__main__":
XiaoAi.setup_mode() XiaoAI.setup_mode()
setup_logging() setup_logging()
setup_graceful_shutdown() setup_graceful_shutdown()
sys.exit(main()) sys.exit(main())
+21
View File
@@ -1,6 +1,8 @@
use open_xiaoai::services::connect::message::MessageManager; use open_xiaoai::services::connect::message::MessageManager;
use open_xiaoai::services::connect::rpc::RPC;
use pyo3::prelude::*; use pyo3::prelude::*;
use pyo3::types::PyBytes; use pyo3::types::PyBytes;
use serde_json::json;
use server::AppServer; use server::AppServer;
pub mod macros; pub mod macros;
@@ -26,10 +28,29 @@ fn start_server(py: Python) -> PyResult<Bound<PyAny>> {
}) })
} }
#[pyfunction]
fn run_shell(py: Python, script: String, timeout_millis: f64) -> PyResult<Bound<PyAny>> {
pyo3_async_runtimes::tokio::future_into_py(py, async move {
let res = RPC::instance()
.call_remote(
"run_shell",
Some(json!(script)),
Some(timeout_millis as u64),
)
.await;
let result = match res {
Err(e) => format!("run_shell error: {}", e),
Ok(res) => serde_json::to_string(&res.data.unwrap()).unwrap(),
};
Ok(result)
})
}
#[pymodule] #[pymodule]
fn open_xiaoai_server(_py: Python, m: Bound<'_, PyModule>) -> PyResult<()> { fn open_xiaoai_server(_py: Python, m: Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(start_server, &m)?)?; m.add_function(wrap_pyfunction!(start_server, &m)?)?;
m.add_function(wrap_pyfunction!(on_output_data, &m)?)?; m.add_function(wrap_pyfunction!(on_output_data, &m)?)?;
m.add_function(wrap_pyfunction!(run_shell, &m)?)?;
crate::python::init_module(&m)?; crate::python::init_module(&m)?;
Ok(()) Ok(())
} }
+5 -3
View File
@@ -1,3 +1,4 @@
use crate::python::PythonManager;
use open_xiaoai::base::{AppError, VERSION}; use open_xiaoai::base::{AppError, VERSION};
use open_xiaoai::services::audio::config::AudioConfig; use open_xiaoai::services::audio::config::AudioConfig;
use open_xiaoai::services::connect::data::{Event, Request, Response, Stream}; use open_xiaoai::services::connect::data::{Event, Request, Response, Stream};
@@ -7,13 +8,12 @@ use open_xiaoai::services::connect::rpc::RPC;
use open_xiaoai::services::speaker::SpeakerManager; use open_xiaoai::services::speaker::SpeakerManager;
use open_xiaoai::utils::task::TaskManager; use open_xiaoai::utils::task::TaskManager;
use pyo3::types::PyBytes; use pyo3::types::PyBytes;
use pyo3::types::PyString;
use pyo3::Python; use pyo3::Python;
use serde_json::json; use serde_json::json;
use tokio::net::{TcpListener, TcpStream}; use tokio::net::{TcpListener, TcpStream};
use tokio_tungstenite::accept_async; use tokio_tungstenite::accept_async;
use crate::python::PythonManager;
pub struct AppServer; pub struct AppServer;
async fn test() -> Result<(), AppError> { async fn test() -> Result<(), AppError> {
@@ -127,6 +127,8 @@ async fn on_stream(stream: Stream) -> Result<(), AppError> {
} }
async fn on_event(event: Event) -> Result<(), AppError> { async fn on_event(event: Event) -> Result<(), AppError> {
crate::pylog!("🔥 收到 Event: {:?}", event); let event_json = serde_json::to_string(&event)?;
let data = Python::with_gil(|py| PyString::new(py, &event_json).into());
PythonManager::instance().call_fn("on_event", Some(data))?;
Ok(()) Ok(())
} }
@@ -1,14 +1,15 @@
import logging import logging
import queue import queue
import numpy as np
import pyaudio
import opuslib
from xiaozhi.services.protocols.typing import AudioConfig
import time
import sys import sys
import time
import numpy as np
import opuslib
import pyaudio
from xiaozhi.services.audio.stream import MyAudio from xiaozhi.services.audio.stream import MyAudio
from xiaozhi.xiaoai import XiaoAi from xiaozhi.services.protocols.typing import AudioConfig
from xiaozhi.xiaoai import XiaoAI
logger = logging.getLogger("AudioCodec") logger = logging.getLogger("AudioCodec")
@@ -24,14 +25,14 @@ class AudioCodec:
self.opus_encoder = None self.opus_encoder = None
self.opus_decoder = None self.opus_decoder = None
self.audio_decode_queue = queue.Queue() self.audio_decode_queue = queue.Queue()
self._is_closing = False self._is_closing = False
self._initialize_audio() self._initialize_audio()
def _initialize_audio(self): def _initialize_audio(self):
"""初始化音频设备和编解码器""" """初始化音频设备和编解码器"""
try: try:
self.audio = MyAudio() if XiaoAi.mode == "xiaoai" else pyaudio.PyAudio() self.audio = MyAudio() if XiaoAI.mode == "xiaoai" else pyaudio.PyAudio()
# 初始化音频输入流 # 初始化音频输入流
self.input_stream = self.audio.open( self.input_stream = self.audio.open(
@@ -50,7 +51,7 @@ class AudioCodec:
output=True, output=True,
frames_per_buffer=AudioConfig.FRAME_SIZE, frames_per_buffer=AudioConfig.FRAME_SIZE,
) )
# 初始化Opus编码器 # 初始化Opus编码器
self.opus_encoder = opuslib.Encoder( self.opus_encoder = opuslib.Encoder(
fs=AudioConfig.SAMPLE_RATE, fs=AudioConfig.SAMPLE_RATE,
@@ -193,7 +194,7 @@ class AudioCodec:
if self.output_stream.is_active(): if self.output_stream.is_active():
self.output_stream.stop_stream() self.output_stream.stop_stream()
self.output_stream.close() self.output_stream.close()
except Exception as e: except Exception:
# logger.warning(f"关闭旧输出流时出错: {e}") # logger.warning(f"关闭旧输出流时出错: {e}")
pass pass
+21
View File
@@ -0,0 +1,21 @@
import json
def to_set(data):
if isinstance(data, list):
return list(set(data))
return data
def json_encode(obj, pretty=False):
try:
return json.dumps(obj, ensure_ascii=False, indent=4 if pretty else None)
except Exception as _:
return None
def json_decode(text):
try:
return json.loads(text)
except Exception:
return None
+61 -4
View File
@@ -1,20 +1,23 @@
import argparse import argparse
import asyncio import asyncio
import threading
import numpy as np import numpy as np
import open_xiaoai_server import open_xiaoai_server
from xiaozhi.services.audio.stream import GlobalStream from xiaozhi.services.audio.stream import GlobalStream
from xiaozhi.utils.base import json_decode
class XiaoAi: class XiaoAI:
mode = "xiaoai" mode = "xiaoai"
loop = asyncio.new_event_loop() sync_loop: asyncio.AbstractEventLoop = None
async_loop: asyncio.AbstractEventLoop = None
@classmethod @classmethod
def setup_mode(cls): def setup_mode(cls):
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="小爱音箱接入小智 AI 演示 | by: https://del.wang" description="小爱音箱接入小智 AI | by: https://del.wang"
) )
parser.add_argument( parser.add_argument(
"--mode", "--mode",
@@ -38,10 +41,64 @@ class XiaoAi:
return await open_xiaoai_server.on_output_data(data) return await open_xiaoai_server.on_output_data(data)
future = on_output_data_async(data) future = on_output_data_async(data)
cls.loop.run_until_complete(future) cls.sync_loop.run_until_complete(future)
@classmethod
async def run_shell(cls, script: str, timeout_millis: float = 10 * 1000):
return await open_xiaoai_server.run_shell(script, timeout_millis)
@classmethod
async def on_event(cls, event: str):
event_json = json_decode(event) or {}
event_data = event_json.get("data", {})
event_type = event_json.get("event")
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"):
line = json_decode(event_data.get("NewLine"))
if (
line
and line.get("header", {}).get("namespace") == "SpeechRecognizer"
and line.get("header", {}).get("name") == "RecognizeResult"
):
text = line.get("payload", {}).get("results")[0].get("text")
if not text:
print("🔥 唤醒小爱同学")
elif text and line.get("payload", {}).get("is_final"):
print(f"🔥 收到用户指令: {text}")
@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()
thread = threading.Thread(target=run_event_loop, daemon=True)
thread.start()
@classmethod
def __on_event(cls, event: str):
asyncio.run_coroutine_threadsafe(
cls.on_event(event),
cls.async_loop,
)
@classmethod @classmethod
async def init_xiaoai(cls): 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_input_data", cls.on_input_data)
open_xiaoai_server.register_fn("on_event", cls.__on_event)
cls.__init_background_event_loop()
await open_xiaoai_server.start_server() await open_xiaoai_server.start_server()
+2 -2
View File
@@ -15,7 +15,7 @@ from xiaozhi.services.protocols.typing import (
) )
from xiaozhi.services.protocols.websocket_protocol import WebsocketProtocol from xiaozhi.services.protocols.websocket_protocol import WebsocketProtocol
from xiaozhi.utils.config_manager import ConfigManager from xiaozhi.utils.config_manager import ConfigManager
from xiaozhi.xiaoai import XiaoAi from xiaozhi.xiaoai import XiaoAI
# 配置日志 # 配置日志
logger = logging.getLogger("XiaoZhi") logger = logging.getLogger("XiaoZhi")
@@ -91,7 +91,7 @@ class XiaoZhi:
time.sleep(0.1) time.sleep(0.1)
# 初始化应用程序(移除自动连接) # 初始化应用程序(移除自动连接)
asyncio.run_coroutine_threadsafe(XiaoAi.init_xiaoai(), self.loop) asyncio.run_coroutine_threadsafe(XiaoAI.init_xiaoai(), self.loop)
asyncio.run_coroutine_threadsafe(self._initialize_without_connect(), self.loop) asyncio.run_coroutine_threadsafe(self._initialize_without_connect(), self.loop)
# 启动主循环线程 # 启动主循环线程