diff --git a/.gitignore b/.gitignore index 985ec651..0a684e6a 100644 --- a/.gitignore +++ b/.gitignore @@ -75,6 +75,7 @@ docs/_build/ # PyBuilder .pybuilder/ target/ +*.pid # Jupyter Notebook .ipynb_checkpoints diff --git a/main/manager-api/README.md b/main/manager-api/README.md index 0491632b..98010c62 100644 --- a/main/manager-api/README.md +++ b/main/manager-api/README.md @@ -81,15 +81,16 @@ src/main/java/xiaozhi/AdminApplication.java 执行以下命令生产jar包 ``` -mvn install +mvn clean install ``` 把jar包放在服务器上,执行 ``` -nohup java -jar xiaozhi-esp32-api.jar --spring.profiles.active=dev >/dev/null & +nohup java -jar xiaozhi-esp32-api.jar --spring.profiles.activate=dev ``` + # 接口文档 启动后打开:http://localhost:8002/xiaozhi-esp32-api/doc.html diff --git a/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/AdminController.java b/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/AdminController.java index ee551400..535fb548 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/AdminController.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/AdminController.java @@ -50,7 +50,7 @@ public class AdminController { AdminPageUserDTO dto = new AdminPageUserDTO(); dto.setMobile((String) params.get("mobile")); dto.setLimit((String) params.get(Constant.LIMIT)); - dto.setPage((String) params.get("pages")); + dto.setPage((String) params.get(Constant.PAGE)); ValidatorUtils.validateEntity(dto); PageData page = sysUserService.page(dto); diff --git a/main/manager-api/src/main/resources/logback-spring.xml b/main/manager-api/src/main/resources/logback-spring.xml index aa6ed7b5..c69d03c0 100644 --- a/main/manager-api/src/main/resources/logback-spring.xml +++ b/main/manager-api/src/main/resources/logback-spring.xml @@ -1,35 +1,139 @@ - - - - - + + + + + + + + + + + + ${LOG_HOME} + true + + + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %highlight(%-5level) %cyan(%logger{50}) - %msg%n + UTF-8 + + + + + + ${LOG_HOME}/xiaozhi-esp32-api.log + + ${LOG_HOME}/xiaozhi-esp32-api.%d{yyyy-MM-dd}.%i.log + 10MB + 30 + 2GB + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n + UTF-8 + + + + + + ${LOG_HOME}/error.log + + ERROR + + + ${LOG_HOME}/error.%d{yyyy-MM-dd}.%i.log + 10MB + 30 + 1GB + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n + UTF-8 + + + + - - + + + + + + + + - - + + + + + + + + + + + + + + - + + + ${LOG_HOME}/xiaozhi-esp32-api.log - - /home/system/logs/admin.%d{yyyy-MM-dd}.%i.log - - 1MB - - 7 + ${LOG_HOME}/xiaozhi-esp32-api.%d{yyyy-MM-dd}.%i.log + 10MB + 30 + 2GB - - + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n + UTF-8 - - - + + + + ${LOG_HOME}/error.log + + ERROR + + + ${LOG_HOME}/error.%d{yyyy-MM-dd}.%i.log + 10MB + 30 + 1GB + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n + UTF-8 + + + + + + + + + + + + + + - - \ No newline at end of file + + + + true + + diff --git a/main/manager-web/README.md b/main/manager-web/README.md index a81c8ff6..945ea29d 100644 --- a/main/manager-web/README.md +++ b/main/manager-web/README.md @@ -6,8 +6,6 @@ 开发使用代码编辑器,导入项目时,选择`manager-web`文件夹作为项目目录 -参照[manager前后端接口协议](https://app.apifox.com/invite/project?token=H_8qhgfjUeaAL0wybghgU)开发 - ``` npm install ``` diff --git a/main/xiaozhi-server/config.yaml b/main/xiaozhi-server/config.yaml index 65681997..d38cdc1f 100644 --- a/main/xiaozhi-server/config.yaml +++ b/main/xiaozhi-server/config.yaml @@ -187,6 +187,14 @@ ASR: access_token: 你的火山引擎语音合成服务access_token cluster: volcengine_input_common output_dir: tmp/ + TencentASR: + # token申请地址:https://console.cloud.tencent.com/cam/capi + # 免费领取资源:https://console.cloud.tencent.com/asr/resourcebundle + type: tencent + appid: 你的腾讯语音合成服务appid + secret_id: 你的腾讯语音合成服务secret_id + secret_key: 你的腾讯语音合成服务secret_key + output_dir: tmp/ VAD: SileroVAD: threshold: 0.5 diff --git a/main/xiaozhi-server/core/providers/asr/tencent.py b/main/xiaozhi-server/core/providers/asr/tencent.py new file mode 100644 index 00000000..8221f9df --- /dev/null +++ b/main/xiaozhi-server/core/providers/asr/tencent.py @@ -0,0 +1,251 @@ +import base64 +import hashlib +import hmac +import json +import time +from datetime import datetime, timezone +import os +import uuid +from typing import Optional, Tuple, List +import wave +import opuslib_next + +import requests +from core.providers.asr.base import ASRProviderBase +from config.logger import setup_logging + +TAG = __name__ +logger = setup_logging() + +class ASRProvider(ASRProviderBase): + API_URL = "https://asr.tencentcloudapi.com" + API_VERSION = "2019-06-14" + FORMAT = "pcm" # 支持的音频格式:pcm, wav, mp3 + + def __init__(self, config: dict, delete_audio_file: bool = True): + self.secret_id = config.get("secret_id") + self.secret_key = config.get("secret_key") + self.output_dir = config.get("output_dir") + + # 确保输出目录存在 + os.makedirs(self.output_dir, exist_ok=True) + + def save_audio_to_file(self, opus_data: List[bytes], session_id: str) -> str: + """将Opus音频数据解码并保存为WAV文件""" + + file_name = f"tencent_asr_{session_id}_{uuid.uuid4()}.wav" + file_path = os.path.join(self.output_dir, file_name) + + decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道 + pcm_data = [] + + for opus_packet in opus_data: + try: + pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms + pcm_data.append(pcm_frame) + except opuslib_next.OpusError as e: + logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True) + + with wave.open(file_path, "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(2) # 2 bytes = 16-bit + wf.setframerate(16000) + wf.writeframes(b"".join(pcm_data)) + + return file_path + + @staticmethod + def decode_opus(opus_data: List[bytes]) -> bytes: + """将Opus音频数据解码为PCM数据""" + import opuslib_next + + decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道 + pcm_data = [] + + for opus_packet in opus_data: + try: + pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms + pcm_data.append(pcm_frame) + except opuslib_next.OpusError as e: + logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True) + + return b"".join(pcm_data) + + async def speech_to_text(self, opus_data: List[bytes], session_id: str) -> Tuple[Optional[str], Optional[str]]: + """将语音数据转换为文本""" + if not opus_data: + logger.bind(tag=TAG).warn("音频数据为空!") + return None, None + + try: + # 检查配置是否已设置 + if not self.secret_id or not self.secret_key: + logger.bind(tag=TAG).error("腾讯云语音识别配置未设置,无法进行识别") + return None, None + + # 将Opus音频数据解码为PCM + pcm_data = self.decode_opus(opus_data) + + # 将音频数据转换为Base64编码 + base64_audio = base64.b64encode(pcm_data).decode('utf-8') + + # 构建请求体 + request_body = self._build_request_body(base64_audio) + + # 获取认证头 + timestamp, authorization = self._get_auth_headers(request_body) + + # 发送请求 + start_time = time.time() + result = self._send_request(request_body, timestamp, authorization) + + if result: + logger.bind(tag=TAG).debug(f"腾讯云语音识别耗时: {time.time() - start_time:.3f}s | 结果: {result}") + + return result, None + + except Exception as e: + logger.bind(tag=TAG).error(f"处理音频时发生错误!{e}", exc_info=True) + return None, None + + def _build_request_body(self, base64_audio: str) -> str: + """构建请求体""" + request_map = { + "ProjectId": 0, + "SubServiceType": 2, # 一句话识别 + "EngSerViceType": "16k_zh", # 中文普通话通用 + "SourceType": 1, # 音频数据来源为语音文件 + "VoiceFormat": self.FORMAT, # 音频格式 + "Data": base64_audio, # Base64编码的音频数据 + "DataLen": len(base64_audio) # 数据长度 + } + return json.dumps(request_map) + + def _get_auth_headers(self, request_body: str) -> Tuple[str, str]: + """获取认证头""" + try: + # 获取当前UTC时间戳 + now = datetime.now(timezone.utc) + timestamp = str(int(now.timestamp())) + date = now.strftime("%Y-%m-%d") + + # 服务名称必须是 "asr" + service = "asr" + + # 拼接凭证范围 + credential_scope = f"{date}/{service}/tc3_request" + + # 使用TC3-HMAC-SHA256签名方法 + algorithm = "TC3-HMAC-SHA256" + + # 构建规范请求字符串 + http_request_method = "POST" + canonical_uri = "/" + canonical_query_string = "" + + # 注意:头部信息需要按照ASCII升序排列,且key和value都转为小写 + # 必须包含content-type和host头部 + content_type = "application/json; charset=utf-8" + host = "asr.tencentcloudapi.com" + action = "SentenceRecognition" # 接口名称 + + # 构建规范头部信息,注意顺序和格式 + canonical_headers = f"content-type:{content_type.lower()}\n" + \ + f"host:{host.lower()}\n" + \ + f"x-tc-action:{action.lower()}\n" + + signed_headers = "content-type;host;x-tc-action" + + # 请求体哈希值 + payload_hash = self._sha256_hex(request_body) + + # 构建规范请求字符串 + canonical_request = f"{http_request_method}\n" + \ + f"{canonical_uri}\n" + \ + f"{canonical_query_string}\n" + \ + f"{canonical_headers}\n" + \ + f"{signed_headers}\n" + \ + f"{payload_hash}" + + # 计算规范请求的哈希值 + hashed_canonical_request = self._sha256_hex(canonical_request) + + # 构建待签名字符串 + string_to_sign = f"{algorithm}\n" + \ + f"{timestamp}\n" + \ + f"{credential_scope}\n" + \ + f"{hashed_canonical_request}" + + # 计算签名密钥 + secret_date = self._hmac_sha256(f"TC3{self.secret_key}", date) + secret_service = self._hmac_sha256(secret_date, service) + secret_signing = self._hmac_sha256(secret_service, "tc3_request") + + # 计算签名 + signature = self._bytes_to_hex(self._hmac_sha256(secret_signing, string_to_sign)) + + # 构建授权头 + authorization = f"{algorithm} " + \ + f"Credential={self.secret_id}/{credential_scope}, " + \ + f"SignedHeaders={signed_headers}, " + \ + f"Signature={signature}" + + return timestamp, authorization + + except Exception as e: + logger.bind(tag=TAG).error(f"生成认证头失败: {e}", exc_info=True) + raise RuntimeError(f"生成认证头失败: {e}") + + def _send_request(self, request_body: str, timestamp: str, authorization: str) -> Optional[str]: + """发送请求到腾讯云API""" + headers = { + "Content-Type": "application/json; charset=utf-8", + "Host": "asr.tencentcloudapi.com", + "Authorization": authorization, + "X-TC-Action": "SentenceRecognition", + "X-TC-Version": self.API_VERSION, + "X-TC-Timestamp": timestamp, + "X-TC-Region": "ap-shanghai" + } + + try: + response = requests.post(self.API_URL, headers=headers, data=request_body) + + if not response.ok: + raise IOError(f"请求失败: {response.status_code} {response.reason}") + + response_json = response.json() + + # 检查是否有错误 + if "Response" in response_json and "Error" in response_json["Response"]: + error = response_json["Response"]["Error"] + error_code = error["Code"] + error_message = error["Message"] + raise IOError(f"API返回错误: {error_code}: {error_message}") + + # 提取识别结果 + if "Response" in response_json and "Result" in response_json["Response"]: + return response_json["Response"]["Result"] + else: + logger.bind(tag=TAG).warn(f"响应中没有识别结果: {response_json}") + return "" + + except Exception as e: + logger.bind(tag=TAG).error(f"发送请求失败: {e}", exc_info=True) + return None + + def _sha256_hex(self, data: str) -> str: + """计算字符串的SHA256哈希值""" + digest = hashlib.sha256(data.encode('utf-8')).digest() + return self._bytes_to_hex(digest) + + def _hmac_sha256(self, key, data: str) -> bytes: + """计算HMAC-SHA256""" + if isinstance(key, str): + key = key.encode('utf-8') + + return hmac.new(key, data.encode('utf-8'), hashlib.sha256).digest() + + def _bytes_to_hex(self, bytes_data: bytes) -> str: + """字节数组转十六进制字符串""" + return ''.join(f"{b:02x}" for b in bytes_data) \ No newline at end of file diff --git a/main/xiaozhi-server/plugins_func/functions/hass_init.py b/main/xiaozhi-server/plugins_func/functions/hass_init.py index e09eb67f..17846cfa 100644 --- a/main/xiaozhi-server/plugins_func/functions/hass_init.py +++ b/main/xiaozhi-server/plugins_func/functions/hass_init.py @@ -10,7 +10,7 @@ HASS_CACHE = {} def append_devices_to_prompt(conn): if conn.use_function_call_mode: funcs = conn.config["Intent"]["function_call"].get("functions", []) - if "hass_get_state" in funcs or "hass_get_state" in funcs: + if "hass_get_state" in funcs or "hass_set_state" in funcs: prompt = "下面是我家智能设备,可以通过homeassistant控制\n" devices = conn.config["plugins"]["home_assistant"].get("devices", []) if len(devices) == 0: @@ -27,9 +27,13 @@ def initialize_hass_handler(conn): if HASS_CACHE == {}: if conn.use_function_call_mode: funcs = conn.config["Intent"]["function_call"].get("functions", []) - if "hass_get_state" in funcs or "hass_get_state" in funcs: - HASS_CACHE['base_url'] = conn.config["plugins"]["home_assistant"].get("base_url") - HASS_CACHE['api_key'] = conn.config["plugins"]["home_assistant"].get("api_key") + if "hass_get_state" in funcs or "hass_set_state" in funcs: + HASS_CACHE["base_url"] = conn.config["plugins"]["home_assistant"].get( + "base_url" + ) + HASS_CACHE["api_key"] = conn.config["plugins"]["home_assistant"].get( + "api_key" + ) - check_model_key("home_assistant", HASS_CACHE['api_key']) + check_model_key("home_assistant", HASS_CACHE["api_key"]) return HASS_CACHE