From d86a2cf9de36e1e9c65b8c63835ad66ed8f1636a Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Thu, 29 May 2025 10:38:01 +0800 Subject: [PATCH 1/6] =?UTF-8?q?update:=E6=8A=BD=E7=A6=BB=E5=85=AC=E5=85=B1?= =?UTF-8?q?=E6=96=B9=E6=B3=95save=5Faudio=5Fto=5Ffile=E5=88=B0=E7=88=B6?= =?UTF-8?q?=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/providers/asr/aliyun.py | 17 ------------- .../core/providers/asr/baidu.py | 14 ----------- .../xiaozhi-server/core/providers/asr/base.py | 20 ++++++++++++---- .../core/providers/asr/doubao.py | 24 +++---------------- .../core/providers/asr/fun_local.py | 14 ----------- .../core/providers/asr/fun_server.py | 14 ----------- .../core/providers/asr/sherpa_onnx_local.py | 14 ----------- .../core/providers/asr/tencent.py | 16 ------------- 8 files changed, 19 insertions(+), 114 deletions(-) diff --git a/main/xiaozhi-server/core/providers/asr/aliyun.py b/main/xiaozhi-server/core/providers/asr/aliyun.py index 74fb4091..5fa18253 100644 --- a/main/xiaozhi-server/core/providers/asr/aliyun.py +++ b/main/xiaozhi-server/core/providers/asr/aliyun.py @@ -3,8 +3,6 @@ import json import asyncio from typing import Optional, Tuple, List import opuslib_next -import wave -import io import os import uuid import hmac @@ -165,21 +163,6 @@ class ASRProvider(ASRProviderBase): request += "&enable_voice_detection=false" return request - def save_audio_to_file(self, pcm_data: List[bytes], session_id: str) -> str: - """PCM数据保存为WAV文件""" - module_name = __name__.split(".")[-1] - file_name = f"asr_{module_name}_{session_id}_{uuid.uuid4()}.wav" - file_path = os.path.join(self.output_dir, file_name) - - with wave.open(file_path, "wb") as wf: - wf.setnchannels(1) # 单声道 - wf.setsampwidth(2) # 16-bit - wf.setframerate(self.sample_rate) - wf.writeframes(b"".join(pcm_data)) - - logger.bind(tag=TAG).debug(f"音频文件已保存至: {file_path}") - return file_path - async def _send_request(self, pcm_data: bytes) -> Optional[str]: """发送请求到阿里云ASR服务""" try: diff --git a/main/xiaozhi-server/core/providers/asr/baidu.py b/main/xiaozhi-server/core/providers/asr/baidu.py index fe73fd45..87cb6a71 100644 --- a/main/xiaozhi-server/core/providers/asr/baidu.py +++ b/main/xiaozhi-server/core/providers/asr/baidu.py @@ -36,20 +36,6 @@ class ASRProvider(ASRProviderBase): # 确保输出目录存在 os.makedirs(self.output_dir, exist_ok=True) - def save_audio_to_file(self, pcm_data: List[bytes], session_id: str) -> str: - """PCM数据保存为WAV文件""" - module_name = __name__.split(".")[-1] - file_name = f"asr_{module_name}_{session_id}_{uuid.uuid4()}.wav" - file_path = os.path.join(self.output_dir, file_name) - - 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 - async def speech_to_text( self, opus_data: List[bytes], session_id: str ) -> Tuple[Optional[str], Optional[str]]: diff --git a/main/xiaozhi-server/core/providers/asr/base.py b/main/xiaozhi-server/core/providers/asr/base.py index 9d974180..153f40e2 100644 --- a/main/xiaozhi-server/core/providers/asr/base.py +++ b/main/xiaozhi-server/core/providers/asr/base.py @@ -1,7 +1,10 @@ -from abc import ABC, abstractmethod -from typing import Optional, Tuple, List +import os +import uuid +import wave import opuslib_next +from abc import ABC, abstractmethod from config.logger import setup_logging +from typing import Optional, Tuple, List TAG = __name__ logger = setup_logging() @@ -11,10 +14,19 @@ class ASRProviderBase(ABC): def __init__(self): self.audio_format = "opus" - @abstractmethod def save_audio_to_file(self, pcm_data: List[bytes], session_id: str) -> str: """PCM数据保存为WAV文件""" - pass + module_name = __name__.split(".")[-1] + file_name = f"asr_{module_name}_{session_id}_{uuid.uuid4()}.wav" + file_path = os.path.join(self.output_dir, file_name) + + 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 @abstractmethod async def speech_to_text( diff --git a/main/xiaozhi-server/core/providers/asr/doubao.py b/main/xiaozhi-server/core/providers/asr/doubao.py index 403c181f..bd33af40 100644 --- a/main/xiaozhi-server/core/providers/asr/doubao.py +++ b/main/xiaozhi-server/core/providers/asr/doubao.py @@ -1,17 +1,13 @@ import time -import io -import wave import os -from typing import Optional, Tuple, List import uuid -import websockets import json import gzip - -import opuslib_next +import websockets +from config.logger import setup_logging +from typing import Optional, Tuple, List from core.providers.asr.base import ASRProviderBase -from config.logger import setup_logging TAG = __name__ logger = setup_logging() @@ -102,20 +98,6 @@ class ASRProvider(ASRProviderBase): # 确保输出目录存在 os.makedirs(self.output_dir, exist_ok=True) - def save_audio_to_file(self, pcm_data: List[bytes], session_id: str) -> str: - """PCM数据保存为WAV文件""" - module_name = __name__.split(".")[-1] - file_name = f"asr_{module_name}_{session_id}_{uuid.uuid4()}.wav" - file_path = os.path.join(self.output_dir, file_name) - - 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 _generate_header( message_type=CLIENT_FULL_REQUEST, message_type_specific_flags=NO_SEQUENCE diff --git a/main/xiaozhi-server/core/providers/asr/fun_local.py b/main/xiaozhi-server/core/providers/asr/fun_local.py index c8446574..e3b7fc83 100644 --- a/main/xiaozhi-server/core/providers/asr/fun_local.py +++ b/main/xiaozhi-server/core/providers/asr/fun_local.py @@ -53,20 +53,6 @@ class ASRProvider(ASRProviderBase): # device="cuda:0", # 启用GPU加速 ) - def save_audio_to_file(self, pcm_data: List[bytes], session_id: str) -> str: - """PCM数据保存为WAV文件""" - module_name = __name__.split(".")[-1] - file_name = f"asr_{module_name}_{session_id}_{uuid.uuid4()}.wav" - file_path = os.path.join(self.output_dir, file_name) - - 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 - async def speech_to_text( self, opus_data: List[bytes], session_id: str ) -> Tuple[Optional[str], Optional[str]]: diff --git a/main/xiaozhi-server/core/providers/asr/fun_server.py b/main/xiaozhi-server/core/providers/asr/fun_server.py index 97c9fdc9..ff411340 100644 --- a/main/xiaozhi-server/core/providers/asr/fun_server.py +++ b/main/xiaozhi-server/core/providers/asr/fun_server.py @@ -43,20 +43,6 @@ class ASRProvider(ASRProviderBase): self.ssl_context.check_hostname = False self.ssl_context.verify_mode = ssl.CERT_NONE - def save_audio_to_file(self, pcm_data: List[bytes], session_id: str) -> str: - """PCM数据保存为WAV文件""" - module_name = __name__.split(".")[-1] - file_name = f"asr_{module_name}_{session_id}_{uuid.uuid4()}.wav" - file_path = os.path.join(self.output_dir, file_name) - - 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 - async def _receive_responses(self, ws) -> None: """ Asynchronous generator to receive messages from the WebSocket. diff --git a/main/xiaozhi-server/core/providers/asr/sherpa_onnx_local.py b/main/xiaozhi-server/core/providers/asr/sherpa_onnx_local.py index 667e1606..24522d83 100644 --- a/main/xiaozhi-server/core/providers/asr/sherpa_onnx_local.py +++ b/main/xiaozhi-server/core/providers/asr/sherpa_onnx_local.py @@ -84,20 +84,6 @@ class ASRProvider(ASRProviderBase): use_itn=True, ) - def save_audio_to_file(self, pcm_data: List[bytes], session_id: str) -> str: - """PCM数据保存为WAV文件""" - module_name = __name__.split(".")[-1] - file_name = f"asr_{module_name}_{session_id}_{uuid.uuid4()}.wav" - file_path = os.path.join(self.output_dir, file_name) - - 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 - def read_wave(self, wave_filename: str) -> Tuple[np.ndarray, int]: """ Args: diff --git a/main/xiaozhi-server/core/providers/asr/tencent.py b/main/xiaozhi-server/core/providers/asr/tencent.py index 5ab1befb..22a7ed94 100644 --- a/main/xiaozhi-server/core/providers/asr/tencent.py +++ b/main/xiaozhi-server/core/providers/asr/tencent.py @@ -8,8 +8,6 @@ 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 @@ -33,20 +31,6 @@ class ASRProvider(ASRProviderBase): # 确保输出目录存在 os.makedirs(self.output_dir, exist_ok=True) - def save_audio_to_file(self, pcm_data: List[bytes], session_id: str) -> str: - """PCM数据保存为WAV文件""" - module_name = __name__.split(".")[-1] - file_name = f"asr_{module_name}_{session_id}_{uuid.uuid4()}.wav" - file_path = os.path.join(self.output_dir, file_name) - - 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 - async def speech_to_text( self, opus_data: List[bytes], session_id: str ) -> Tuple[Optional[str], Optional[str]]: From bb42095ca0a6ec7afb71d740e211548be0fb3eea Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Thu, 29 May 2025 23:56:34 +0800 Subject: [PATCH 2/6] =?UTF-8?q?update:=E5=85=BC=E5=AE=B9=E8=B1=86=E5=8C=85?= =?UTF-8?q?=E6=B5=81=E5=BC=8FASR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/core/connection.py | 38 +- .../core/handle/intentHandler.py | 2 - .../core/handle/receiveAudioHandle.py | 59 +- .../core/handle/sendAudioHandle.py | 5 + main/xiaozhi-server/core/handle/textHandle.py | 5 +- main/xiaozhi-server/core/ota_server.py | 4 +- .../core/providers/asr/aliyun.py | 3 +- .../core/providers/asr/baidu.py | 11 +- .../xiaozhi-server/core/providers/asr/base.py | 53 ++ .../core/providers/asr/doubao.py | 584 +++++++++++------- .../core/providers/asr/dto/dto.py | 9 + .../core/providers/asr/fun_local.py | 4 +- .../core/providers/asr/fun_server.py | 6 +- .../core/providers/asr/sherpa_onnx_local.py | 4 +- .../core/providers/asr/tencent.py | 4 +- .../core/utils/modules_initialize.py | 128 ++++ main/xiaozhi-server/core/utils/util.py | 118 ---- main/xiaozhi-server/core/websocket_server.py | 3 +- 18 files changed, 630 insertions(+), 410 deletions(-) create mode 100644 main/xiaozhi-server/core/providers/asr/dto/dto.py create mode 100644 main/xiaozhi-server/core/utils/modules_initialize.py diff --git a/main/xiaozhi-server/core/connection.py b/main/xiaozhi-server/core/connection.py index 04c352bf..b8f06f70 100644 --- a/main/xiaozhi-server/core/connection.py +++ b/main/xiaozhi-server/core/connection.py @@ -12,18 +12,22 @@ import subprocess import websockets from core.utils.util import ( extract_json_from_string, - initialize_modules, check_vad_update, check_asr_update, filter_sensitive_info, - initialize_tts, ) from typing import Dict, Any from core.mcp.manager import MCPManager +from core.utils.modules_initialize import ( + initialize_modules, + initialize_tts, + initialize_asr, +) from core.handle.reportHandle import report from core.providers.tts.default import DefaultTTS from concurrent.futures import ThreadPoolExecutor from core.utils.dialogue import Message, Dialogue +from core.providers.asr.dto.dto import InterfaceType from core.handle.textHandle import handleTextMessage from core.handle.functionHandler import FunctionHandler from plugins_func.loadplugins import auto_import_modules @@ -112,7 +116,6 @@ class ConnectionHandler: # asr相关变量 self.asr_audio = [] - self.asr_server_receive = True # llm相关变量 self.llm_finish_task = True @@ -315,10 +318,14 @@ class ConnectionHandler: if self.vad is None: self.vad = self._vad if self.asr is None: - self.asr = self._asr + self.asr = self._initialize_asr() + # 打开语音识别通道 + asyncio.run_coroutine_threadsafe( + self.asr.open_audio_channels(self), self.loop + ) if self.tts is None: self.tts = self._initialize_tts() - # 使用事件循环运行异步方法 + # 打开语音合成通道 asyncio.run_coroutine_threadsafe( self.tts.open_audio_channels(self), self.loop ) @@ -356,6 +363,19 @@ class ConnectionHandler: return tts + def _initialize_asr(self): + """初始化ASR""" + if self._asr.interface_type == InterfaceType.LOCAL: + # 如果公共ASR是本地服务,则直接返回 + # 因为本地一个实例ASR,可以被多个连接共享 + asr = self._asr + else: + # 如果公共ASR是远程服务,则初始化一个新实例 + # 因为远程ASR,涉及到websocket连接和接收线程,需要每个连接一个实例 + asr = initialize_asr(self.config) + + return asr + def _initialize_private_config(self): """如果是从配置文件获取,则进行二次实例化""" if not self.read_config_from_api: @@ -597,6 +617,8 @@ class ConnectionHandler: text_index = 0 for response in llm_responses: + if self.client_abort: + break if functions is not None: content, tools_call = response if "content" in response: @@ -622,9 +644,6 @@ class ConnectionHandler: if content is not None and len(content) > 0: if not tool_call_flag: response_message.append(content) - if self.client_abort: - break - if text_index == 0: self.tts.tts_text_queue.put( TTSMessageDTO( @@ -801,9 +820,7 @@ class ConnectionHandler: item = self.report_queue.get(timeout=1) if item is None: # 检测毒丸对象 break - type, text, audio_data, report_time = item - try: # 检查线程池状态 if self.executor is None: @@ -834,7 +851,6 @@ class ConnectionHandler: def clearSpeakStatus(self): self.logger.bind(tag=TAG).debug(f"清除服务端讲话状态") - self.asr_server_receive = True async def close(self, ws=None): """资源清理方法""" diff --git a/main/xiaozhi-server/core/handle/intentHandler.py b/main/xiaozhi-server/core/handle/intentHandler.py index 4c876550..cb2e3c69 100644 --- a/main/xiaozhi-server/core/handle/intentHandler.py +++ b/main/xiaozhi-server/core/handle/intentHandler.py @@ -1,4 +1,3 @@ -from config.logger import setup_logging import json import uuid from core.handle.sendAudioHandle import send_stt_message @@ -146,4 +145,3 @@ async def process_intent_result(conn, intent_result, original_text): def speak_txt(conn, text): conn.tts.tts_one_sentence(conn, ContentType.TEXT, content_detail=text) conn.dialogue.put(Message(role="assistant", content=text)) - diff --git a/main/xiaozhi-server/core/handle/receiveAudioHandle.py b/main/xiaozhi-server/core/handle/receiveAudioHandle.py index 18482a04..6776dd0f 100644 --- a/main/xiaozhi-server/core/handle/receiveAudioHandle.py +++ b/main/xiaozhi-server/core/handle/receiveAudioHandle.py @@ -1,10 +1,7 @@ -import time -import copy -from core.utils.util import remove_punctuation_and_length from core.handle.sendAudioHandle import send_stt_message from core.handle.intentHandler import handle_user_intent from core.utils.output_counter import check_device_output_limit -from core.handle.reportHandle import enqueue_asr_report +import time from core.handle.sendAudioHandle import SentenceType from core.utils.util import audio_to_data @@ -13,46 +10,17 @@ TAG = __name__ async def handleAudioMessage(conn, audio): if conn.vad is None: + conn.logger.bind(tag=TAG).warning("VAD模块未初始化,继续等待") return - if not conn.asr_server_receive: - conn.logger.bind(tag=TAG).debug(f"前期数据处理中,暂停接收") + if conn.asr is None: + conn.logger.bind(tag=TAG).warning("ASR模块未初始化,继续等待") return - if conn.client_listen_mode == "auto" or conn.client_listen_mode == "realtime": - have_voice = conn.vad.is_vad(conn, audio) - else: - have_voice = conn.client_have_voice - - # 如果本次没有声音,本段也没声音,就把声音丢弃了 - if have_voice == False and conn.client_have_voice == False: - await no_voice_close_connect(conn) - conn.asr_audio.append(audio) - conn.asr_audio = conn.asr_audio[ - -10: - ] # 保留最新的10帧音频内容,解决ASR句首丢字问题 - return - conn.client_no_voice_last_time = 0.0 - conn.asr_audio.append(audio) - # 如果本段有声音,且已经停止了 - if conn.client_voice_stop: - conn.client_abort = False - conn.asr_server_receive = False - # 音频太短了,无法识别 - if len(conn.asr_audio) < 15: - conn.asr_server_receive = True - else: - raw_text, _ = await conn.asr.speech_to_text( - conn.asr_audio, conn.session_id - ) # 确保ASR模块返回原始文本 - conn.logger.bind(tag=TAG).info(f"识别文本: {raw_text}") - text_len, _ = remove_punctuation_and_length(raw_text) - if text_len > 0: - # 使用自定义模块进行上报 - await startToChat(conn, raw_text) - enqueue_asr_report(conn, raw_text, copy.deepcopy(conn.asr_audio)) - else: - conn.asr_server_receive = True - conn.asr_audio.clear() - conn.reset_vad_states() + # 当前片段是否有人说话 + have_voice = conn.vad.is_vad(conn, audio) + # 设备长时间空闲检测,用于say goodbye + await no_voice_close_connect(conn, have_voice) + # 接收音频 + await conn.asr.receive_audio(audio, have_voice) async def startToChat(conn, text): @@ -73,7 +41,6 @@ async def startToChat(conn, text): if intent_handled: # 如果意图已被处理,不再进行聊天 - conn.asr_server_receive = True return # 意图未被处理,继续常规聊天流程 @@ -81,7 +48,10 @@ async def startToChat(conn, text): conn.executor.submit(conn.chat, text) -async def no_voice_close_connect(conn): +async def no_voice_close_connect(conn, have_voice): + if have_voice: + conn.client_no_voice_last_time = 0.0 + return if conn.client_no_voice_last_time == 0.0: conn.client_no_voice_last_time = time.time() * 1000 else: @@ -95,7 +65,6 @@ async def no_voice_close_connect(conn): ): conn.close_after_chat = True conn.client_abort = False - conn.asr_server_receive = False end_prompt = conn.config.get("end_prompt", {}) if end_prompt and end_prompt.get("enable", True) is False: conn.logger.bind(tag=TAG).info("结束对话,无需发送结束提示语") diff --git a/main/xiaozhi-server/core/handle/sendAudioHandle.py b/main/xiaozhi-server/core/handle/sendAudioHandle.py index 5694c618..6625838c 100644 --- a/main/xiaozhi-server/core/handle/sendAudioHandle.py +++ b/main/xiaozhi-server/core/handle/sendAudioHandle.py @@ -131,6 +131,11 @@ async def send_tts_message(conn, state, text=None): async def send_stt_message(conn, text): + end_prompt_str = conn.config.get("end_prompt", {}).get("prompt") + if end_prompt_str and end_prompt_str == text: + await send_tts_message(conn, "start") + return + """发送 STT 状态消息""" stt_text = get_string_no_punctuation_or_emoji(text) await conn.websocket.send( diff --git a/main/xiaozhi-server/core/handle/textHandle.py b/main/xiaozhi-server/core/handle/textHandle.py index da6ede47..602f72aa 100644 --- a/main/xiaozhi-server/core/handle/textHandle.py +++ b/main/xiaozhi-server/core/handle/textHandle.py @@ -41,12 +41,13 @@ async def handleTextMessage(conn, message): if len(conn.asr_audio) > 0: await handleAudioMessage(conn, b"") elif msg_json["state"] == "detect": - conn.asr_server_receive = False conn.client_have_voice = False conn.asr_audio.clear() if "text" in msg_json: original_text = msg_json["text"] # 保留原始文本 - filtered_len, filtered_text = remove_punctuation_and_length(original_text) + filtered_len, filtered_text = remove_punctuation_and_length( + original_text + ) # 识别是否是唤醒词 is_wakeup_words = filtered_text in conn.config.get("wakeup_words") diff --git a/main/xiaozhi-server/core/ota_server.py b/main/xiaozhi-server/core/ota_server.py index 89e36869..5f89a503 100644 --- a/main/xiaozhi-server/core/ota_server.py +++ b/main/xiaozhi-server/core/ota_server.py @@ -3,8 +3,8 @@ import time import asyncio from aiohttp import web from config.logger import setup_logging -from core.connection import ConnectionHandler -from core.utils.util import get_local_ip, initialize_modules +from core.utils.util import get_local_ip +from core.utils.modules_initialize import initialize_modules TAG = __name__ diff --git a/main/xiaozhi-server/core/providers/asr/aliyun.py b/main/xiaozhi-server/core/providers/asr/aliyun.py index 5fa18253..7ed24c00 100644 --- a/main/xiaozhi-server/core/providers/asr/aliyun.py +++ b/main/xiaozhi-server/core/providers/asr/aliyun.py @@ -2,7 +2,6 @@ import http.client import json import asyncio from typing import Optional, Tuple, List -import opuslib_next import os import uuid import hmac @@ -14,6 +13,7 @@ import time from datetime import datetime from config.logger import setup_logging from core.providers.asr.base import ASRProviderBase +from core.providers.asr.dto.dto import InterfaceType TAG = __name__ logger = setup_logging() @@ -90,6 +90,7 @@ class AccessToken: class ASRProvider(ASRProviderBase): def __init__(self, config: dict, delete_audio_file: bool): super().__init__() + self.interface_type = InterfaceType.NON_STREAM """阿里云ASR初始化""" # 新增空值判断逻辑 self.access_key_id = config.get("access_key_id") diff --git a/main/xiaozhi-server/core/providers/asr/baidu.py b/main/xiaozhi-server/core/providers/asr/baidu.py index 87cb6a71..7df94ff3 100644 --- a/main/xiaozhi-server/core/providers/asr/baidu.py +++ b/main/xiaozhi-server/core/providers/asr/baidu.py @@ -1,18 +1,10 @@ -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 - from aip import AipSpeech from core.providers.asr.base import ASRProviderBase from config.logger import setup_logging +from core.providers.asr.dto.dto import InterfaceType TAG = __name__ logger = setup_logging() @@ -21,6 +13,7 @@ logger = setup_logging() class ASRProvider(ASRProviderBase): def __init__(self, config: dict, delete_audio_file: bool = True): super().__init__() + self.interface_type = InterfaceType.NON_STREAM self.app_id = config.get("app_id") self.api_key = config.get("api_key") self.secret_key = config.get("secret_key") diff --git a/main/xiaozhi-server/core/providers/asr/base.py b/main/xiaozhi-server/core/providers/asr/base.py index 153f40e2..d3ce5ee0 100644 --- a/main/xiaozhi-server/core/providers/asr/base.py +++ b/main/xiaozhi-server/core/providers/asr/base.py @@ -1,10 +1,15 @@ import os +import time +import copy import uuid import wave import opuslib_next from abc import ABC, abstractmethod from config.logger import setup_logging from typing import Optional, Tuple, List +from core.utils.util import remove_punctuation_and_length +from core.handle.reportHandle import enqueue_asr_report +from core.handle.receiveAudioHandle import startToChat TAG = __name__ logger = setup_logging() @@ -13,6 +18,54 @@ logger = setup_logging() class ASRProviderBase(ABC): def __init__(self): self.audio_format = "opus" + self.conn = None + + # 打开音频通道 + # 这里默认是非流式的处理方式 + # 流式处理方式请在子类中重写 + async def open_audio_channels(self, conn): + self.conn = conn + + # 接收音频 + # 这里默认是非流式的处理方式 + # 流式处理方式请在子类中重写 + async def receive_audio(self, audio, audio_have_voice): + if ( + self.conn.client_listen_mode == "auto" + or self.conn.client_listen_mode == "realtime" + ): + have_voice = audio_have_voice + else: + have_voice = self.conn.client_have_voice + # 如果本次没有声音,本段也没声音,就把声音丢弃了 + if have_voice == False and self.conn.client_have_voice == False: + self.conn.asr_audio.append(audio) + self.conn.asr_audio = self.conn.asr_audio[-10:] + return + + # 如果本段有声音,且已经停止了 + if self.conn.client_voice_stop: + self.conn.client_abort = False + # 音频太短了,无法识别 + if len(self.conn.asr_audio) < 15: + self.conn.asr_audio.clear() + self.conn.reset_vad_states() + else: + await self.handle_voice_stop() + + # 处理语音停止 + async def handle_voice_stop(self): + raw_text, _ = await self.speech_to_text( + self.conn.asr_audio, self.conn.session_id + ) # 确保ASR模块返回原始文本 + self.conn.logger.bind(tag=TAG).info(f"识别文本: {raw_text}") + text_len, _ = remove_punctuation_and_length(raw_text) + if text_len > 0: + # 使用自定义模块进行上报 + await startToChat(self.conn, raw_text) + enqueue_asr_report(self.conn, raw_text, copy.deepcopy(self.conn.asr_audio)) + self.conn.asr_audio.clear() + self.conn.reset_vad_states() def save_audio_to_file(self, pcm_data: List[bytes], session_id: str) -> str: """PCM数据保存为WAV文件""" diff --git a/main/xiaozhi-server/core/providers/asr/doubao.py b/main/xiaozhi-server/core/providers/asr/doubao.py index bd33af40..d738f508 100644 --- a/main/xiaozhi-server/core/providers/asr/doubao.py +++ b/main/xiaozhi-server/core/providers/asr/doubao.py @@ -1,265 +1,431 @@ -import time -import os -import uuid import json import gzip +import uuid +import asyncio import websockets -from config.logger import setup_logging -from typing import Optional, Tuple, List +import opuslib_next from core.providers.asr.base import ASRProviderBase - +from config.logger import setup_logging +from core.providers.asr.dto.dto import InterfaceType +import threading TAG = __name__ logger = setup_logging() CLIENT_FULL_REQUEST = 0b0001 CLIENT_AUDIO_ONLY_REQUEST = 0b0010 - -NO_SEQUENCE = 0b0000 -NEG_SEQUENCE = 0b0010 - SERVER_FULL_RESPONSE = 0b1001 SERVER_ACK = 0b1011 SERVER_ERROR_RESPONSE = 0b1111 - -NO_SERIALIZATION = 0b0000 -JSON = 0b0001 -THRIFT = 0b0011 -CUSTOM_TYPE = 0b1111 -NO_COMPRESSION = 0b0000 -GZIP = 0b0001 -CUSTOM_COMPRESSION = 0b1111 - - -def parse_response(res): - """ - protocol_version(4 bits), header_size(4 bits), - message_type(4 bits), message_type_specific_flags(4 bits) - serialization_method(4 bits) message_compression(4 bits) - reserved (8bits) 保留字段 - header_extensions 扩展头(大小等于 8 * 4 * (header_size - 1) ) - payload 类似与http 请求体 - """ - protocol_version = res[0] >> 4 - header_size = res[0] & 0x0F - message_type = res[1] >> 4 - message_type_specific_flags = res[1] & 0x0F - serialization_method = res[2] >> 4 - message_compression = res[2] & 0x0F - reserved = res[3] - header_extensions = res[4 : header_size * 4] - payload = res[header_size * 4 :] - result = {} - payload_msg = None - payload_size = 0 - if message_type == SERVER_FULL_RESPONSE: - payload_size = int.from_bytes(payload[:4], "big", signed=True) - payload_msg = payload[4:] - elif message_type == SERVER_ACK: - seq = int.from_bytes(payload[:4], "big", signed=True) - result["seq"] = seq - if len(payload) >= 8: - payload_size = int.from_bytes(payload[4:8], "big", signed=False) - payload_msg = payload[8:] - elif message_type == SERVER_ERROR_RESPONSE: - code = int.from_bytes(payload[:4], "big", signed=False) - result["code"] = code - payload_size = int.from_bytes(payload[4:8], "big", signed=False) - payload_msg = payload[8:] - if payload_msg is None: - return result - if message_compression == GZIP: - payload_msg = gzip.decompress(payload_msg) - if serialization_method == JSON: - payload_msg = json.loads(str(payload_msg, "utf-8")) - elif serialization_method != NO_SERIALIZATION: - payload_msg = str(payload_msg, "utf-8") - result["payload_msg"] = payload_msg - result["payload_size"] = payload_size - return result +NO_SEQUENCE = 0b0000 +NEG_SEQUENCE = 0b0010 +JSON_SERIALIZATION = 0b0001 +GZIP_COMPRESSION = 0b0001 +PROTOCOL_VERSION = 0b0001 class ASRProvider(ASRProviderBase): - def __init__(self, config: dict, delete_audio_file: bool): + def __init__(self, config, delete_audio_file): super().__init__() - self.appid = config.get("appid") + self.interface_type = InterfaceType.STREAM + self.config = config + self.text = "" + self.max_retries = 3 + self.retry_delay = 2 # 重试延迟秒数 + self.recv_lock = asyncio.Lock() # 添加接收锁 + + self.appid = str(config.get("appid")) self.cluster = config.get("cluster") self.access_token = config.get("access_token") self.boosting_table_name = config.get("boosting_table_name", "") self.correct_table_name = config.get("correct_table_name", "") - self.output_dir = config.get("output_dir") + self.output_dir = config.get("output_dir", "temp/") self.delete_audio_file = delete_audio_file - self.host = "openspeech.bytedance.com" - self.ws_url = f"wss://{self.host}/api/v2/asr" - self.success_code = 1000 - self.seg_duration = 15000 + self.ws_url = "wss://openspeech.bytedance.com/api/v2/asr" + self.uid = config.get("uid", "streaming_asr_service") + self.workflow = config.get( + "workflow", "audio_in,resample,partition,vad,fe,decode,itn,nlu_punctuate" + ) + self.result_type = config.get("result_type", "single") + self.format = config.get("format", "raw") + self.codec = config.get("codec", "pcm") + self.rate = config.get("sample_rate", 16000) + self.language = config.get("language", "zh-CN") + self.bits = config.get("bits", 16) + self.channel = config.get("channel", 1) + self.auth_method = config.get("auth_method", "token") + self.secret = config.get("secret", "access_secret") + self.decoder = opuslib_next.Decoder(16000, 1) + self.asr_ws = None + self.forward_task = None + self.conn = None + self.asr_thread = None - # 确保输出目录存在 - os.makedirs(self.output_dir, exist_ok=True) + ################################################################################### + # 豆包流式ASR重写父类的方法--开始 + ################################################################################### + async def open_audio_channels(self, conn): + await super().open_audio_channels(conn) - @staticmethod - def _generate_header( - message_type=CLIENT_FULL_REQUEST, message_type_specific_flags=NO_SEQUENCE - ) -> bytearray: - """Generate protocol header.""" - header = bytearray() - header_size = 1 - header.append((0b0001 << 4) | header_size) # Protocol version - header.append((message_type << 4) | message_type_specific_flags) - header.append((0b0001 << 4) | 0b0001) # JSON serialization & GZIP compression - header.append(0x00) # reserved - return header + retry_count = 0 + while retry_count < self.max_retries: + try: + # 确保关闭旧的连接 + if self.asr_ws is not None: + try: + await self.asr_ws.close() + except Exception as e: + logger.bind(tag=TAG).warning(f"关闭旧连接时发生错误: {e}") + finally: + self.asr_ws = None - def _construct_request(self, reqid) -> dict: - """Construct the request payload.""" - return { + headers = self.token_auth() if self.auth_method == "token" else None + self.asr_ws = await websockets.connect( + self.ws_url, + additional_headers=headers, + max_size=1000000000, + ping_interval=None, # 禁用ping,因为服务器可能不支持 + ping_timeout=None, + close_timeout=10, + ) + + # 发送初始化请求 + request_params = self.construct_request(str(uuid.uuid4())) + try: + payload_bytes = str.encode(json.dumps(request_params)) + payload_bytes = gzip.compress(payload_bytes) + full_client_request = self.generate_header() + full_client_request.extend((len(payload_bytes)).to_bytes(4, "big")) + full_client_request.extend(payload_bytes) + await self.asr_ws.send(full_client_request) + logger.bind(tag=TAG).debug(f"发送初始化请求: {request_params}") + except Exception as e: + logger.bind(tag=TAG).error(f"发送初始化请求失败: {e}") + raise e + + # 等待初始化响应 + try: + init_res = await self.asr_ws.recv() + logger.bind(tag=TAG).debug(f"收到原始响应: {init_res}") + result = self.parse_response(init_res) + logger.bind(tag=TAG).info(f"ASR服务初始化响应: {result}") + except Exception as e: + logger.bind(tag=TAG).error(f"ASR服务初始化失败: {e}") + raise e + + # 启动接收ASR结果的异步任务 + asr_priority = threading.Thread( + target=self._start_monitor_asr_response_thread, daemon=True + ) + asr_priority.start() + return + + except websockets.exceptions.WebSocketException as e: + retry_count += 1 + if retry_count < self.max_retries: + logger.bind(tag=TAG).warning( + f"WebSocket连接失败,正在进行第{retry_count}次重试: {e}" + ) + await asyncio.sleep(self.retry_delay) + else: + logger.bind(tag=TAG).error( + f"WebSocket连接失败,已达到最大重试次数: {e}" + ) + raise + except Exception as e: + logger.bind(tag=TAG).error(f"WebSocket连接发生未知错误: {e}") + raise + + async def receive_audio(self, audio, _): + if not isinstance(audio, bytes): + return + + try: + # 解码opus得到PCM数据 + pcm_frame = self.decoder.decode(audio, 960) + payload = gzip.compress(pcm_frame) + audio_request = bytearray(self.generate_audio_default_header()) + audio_request.extend(len(payload).to_bytes(4, "big")) + audio_request.extend(payload) + if self.asr_ws: + await self.asr_ws.send(audio_request) + except Exception as e: + logger.bind(tag=TAG).error(f"发送音频数据时发生错误: {e}") + + ################################################################################### + # 豆包流式ASR重写父类的方法--结束 + ################################################################################### + + def construct_request(self, reqid): + req = { "app": { - "appid": f"{self.appid}", + "appid": self.appid, "cluster": self.cluster, "token": self.access_token, }, - "user": { - "uid": str(uuid.uuid4()), - }, + "user": {"uid": self.uid}, "request": { "reqid": reqid, - "show_utterances": False, + "workflow": self.workflow, + "show_utterances": True, + "result_type": self.result_type, "sequence": 1, "boosting_table_name": self.boosting_table_name, "correct_table_name": self.correct_table_name, }, "audio": { - "format": "raw", - "rate": 16000, - "language": "zh-CN", - "bits": 16, - "channel": 1, - "codec": "raw", + "format": self.format, + "codec": self.codec, + "rate": self.rate, + "language": self.language, + "bits": self.bits, + "channel": self.channel, }, } + return req - async def _send_request( - self, audio_data: List[bytes], segment_size: int - ) -> Optional[str]: - """Send request to Volcano ASR service.""" + def token_auth(self): + return {"Authorization": f"Bearer; {self.access_token}"} + + def generate_header( + self, + version=PROTOCOL_VERSION, + message_type=CLIENT_FULL_REQUEST, + message_type_specific_flags=NO_SEQUENCE, + serial_method=JSON_SERIALIZATION, + compression_type=GZIP_COMPRESSION, + reserved_data=0x00, + extension_header: bytes = b"", + ): + """ + 生成协议头: + - 第1字节:高4位:协议版本,低4位:头部大小(单位 4 字节) + - 第2字节:高4位:消息类型,低4位:消息类型特定标志 + - 第3字节:高4位:序列化方式,低4位:压缩方式 + - 第4字节:保留字段 + - 后续:扩展头(如果有) + """ + header = bytearray() + header_size = int(len(extension_header) / 4) + 1 + header.append((version << 4) | header_size) + header.append((message_type << 4) | message_type_specific_flags) + header.append((serial_method << 4) | compression_type) + header.append(reserved_data) + header.extend(extension_header) + return header + + def generate_full_default_header(self): + # full client request 默认头 + return self.generate_header( + version=PROTOCOL_VERSION, + message_type=CLIENT_FULL_REQUEST, + message_type_specific_flags=NO_SEQUENCE, + serial_method=JSON_SERIALIZATION, + compression_type=GZIP_COMPRESSION, + ) + + def generate_audio_default_header(self): + # 普通音频片段请求 + return self.generate_header( + version=PROTOCOL_VERSION, + message_type=CLIENT_AUDIO_ONLY_REQUEST, + message_type_specific_flags=NO_SEQUENCE, + serial_method=JSON_SERIALIZATION, + compression_type=GZIP_COMPRESSION, + ) + + def generate_last_audio_default_header(self): + # 最后一个音频片段标志 + return self.generate_header( + version=PROTOCOL_VERSION, + message_type=CLIENT_AUDIO_ONLY_REQUEST, + message_type_specific_flags=NEG_SEQUENCE, # 用 NEG_SEQUENCE 表示结束 + serial_method=JSON_SERIALIZATION, + compression_type=GZIP_COMPRESSION, + ) + + def _start_monitor_asr_response_thread(self): + # 初始化链接 + asyncio.run_coroutine_threadsafe( + self._forward_asr_results(), loop=self.conn.loop + ) + + async def _forward_asr_results(self): try: - auth_header = {"Authorization": "Bearer; {}".format(self.access_token)} - async with websockets.connect( - self.ws_url, additional_headers=auth_header - ) as websocket: - # Prepare request data - request_params = self._construct_request(str(uuid.uuid4())) - payload_bytes = str.encode(json.dumps(request_params)) - payload_bytes = gzip.compress(payload_bytes) - full_client_request = self._generate_header() - full_client_request.extend( - (len(payload_bytes)).to_bytes(4, "big") - ) # payload size(4 bytes) - full_client_request.extend(payload_bytes) # payload + while not self.conn.stop_event.is_set(): + try: + if self.asr_ws is None: + logger.bind(tag=TAG).info("尝试重新连接ASR服务...") + await self.open_audio_channels(self.conn) + continue - # Send header and metadata - # full_client_request - await websocket.send(full_client_request) - res = await websocket.recv() - result = parse_response(res) - if ( - "payload_msg" in result - and result["payload_msg"]["code"] != self.success_code - ): - logger.bind(tag=TAG).error(f"ASR error: {result}") - return None - - for seq, (chunk, last) in enumerate( - self.slice_data(audio_data, segment_size), 1 - ): - if last: - audio_only_request = self._generate_header( - message_type=CLIENT_AUDIO_ONLY_REQUEST, - message_type_specific_flags=NEG_SEQUENCE, + # 使用锁来确保同一时间只有一个协程在接收数据 + async with self.recv_lock: + response = await self.asr_ws.recv() + result = self.parse_response(response) + # 检查是否需要重连 + if result.get("need_reconnect", False): + logger.bind(tag=TAG).info( + "检测到需要重连的错误,准备重新连接..." ) - else: - audio_only_request = self._generate_header( - message_type=CLIENT_AUDIO_ONLY_REQUEST - ) - payload_bytes = gzip.compress(chunk) - audio_only_request.extend( - (len(payload_bytes)).to_bytes(4, "big") - ) # payload size(4 bytes) - audio_only_request.extend(payload_bytes) # payload - # Send audio data - await websocket.send(audio_only_request) + if self.asr_ws is not None: + try: + await self.asr_ws.close() + except Exception as e: + logger.bind(tag=TAG).warning( + f"关闭旧连接时发生错误: {e}" + ) + finally: + self.asr_ws = None + continue - # Receive response - response = await websocket.recv() - result = parse_response(response) - - if ( - "payload_msg" in result - and result["payload_msg"]["code"] == self.success_code - ): - if len(result["payload_msg"]["result"]) > 0: - return result["payload_msg"]["result"][0]["text"] - return None - else: - logger.bind(tag=TAG).error(f"ASR error: {result}") - return None + if "payload_msg" in result: + if "result" in result["payload_msg"]: + # 检查是否有utterances并且definite为True + utterances = result["payload_msg"]["result"][0].get( + "utterances", [] + ) + for utterance in utterances: + if utterance.get("definite", False): + self.text = utterance["text"] + await self.handle_voice_stop() + break + except websockets.ConnectionClosed: + logger.bind(tag=TAG).debug("ASR服务连接已关闭,准备重连...") + # 确保关闭旧连接 + if self.asr_ws is not None: + try: + await self.asr_ws.close() + except Exception as e: + logger.bind(tag=TAG).warning(f"关闭旧连接时发生错误: {e}") + finally: + self.asr_ws = None + retry_count = 0 + while ( + retry_count < self.max_retries + and not self.conn.stop_event.is_set() + ): + try: + logger.bind(tag=TAG).info( + f"正在进行第{retry_count + 1}次重连尝试..." + ) + await self.open_audio_channels(self.conn) + break + except Exception as e: + retry_count += 1 + if retry_count < self.max_retries: + logger.bind(tag=TAG).warning( + f"重连失败,等待{self.retry_delay}秒后重试: {e}" + ) + await asyncio.sleep(self.retry_delay) + else: + logger.bind(tag=TAG).error( + f"重连失败,已达到最大重试次数: {e}" + ) + await asyncio.sleep( + self.retry_delay + ) # 继续等待,以便后续重试 + except Exception as e: + logger.bind(tag=TAG).error(f"处理ASR结果时发生错误: {e}") + if not self.conn.stop_event.is_set(): + await asyncio.sleep(2) # 增加重试延迟 except Exception as e: - logger.bind(tag=TAG).error(f"ASR request failed: {e}", exc_info=True) - return None + logger.bind(tag=TAG).error(f"ASR监听线程发生错误: {e}") + # 确保在发生严重错误时也能继续尝试重连 + if not self.conn.stop_event.is_set(): + await asyncio.sleep(self.retry_delay) + await self._forward_asr_results() # 递归重试 - @staticmethod - def slice_data(data: bytes, chunk_size: int) -> (list, bool): + async def speech_to_text(self, opus_data, session_id): + result = self.text + self.text = "" # 清空text + return result, None + + def parse_response(self, res: bytes) -> dict: """ - slice data - :param data: wav data - :param chunk_size: the segment size in one request - :return: segment data, last flag + 解析 ASR 服务返回的二进制响应。 + 根据协议格式解析头部和 payload,若采用 GZIP 压缩则先解压,再根据 JSON 反序列化。 """ - data_len = len(data) - offset = 0 - while offset + chunk_size < data_len: - yield data[offset : offset + chunk_size], False - offset += chunk_size + protocol_version = res[0] >> 4 + header_size = res[0] & 0x0F + message_type = res[1] >> 4 + serialization_method = res[2] >> 4 + message_compression = res[2] & 0x0F + payload = res[header_size * 4 :] + result = {} + payload_msg = None + payload_size = 0 + + if message_type == SERVER_FULL_RESPONSE: + payload_size = int.from_bytes(payload[:4], "big", signed=True) + payload_msg = payload[4:] + elif message_type == SERVER_ACK: + seq = int.from_bytes(payload[:4], "big", signed=True) + result["seq"] = seq + if len(payload) >= 8: + payload_size = int.from_bytes(payload[4:8], "big", signed=False) + payload_msg = payload[8:] + elif message_type == SERVER_ERROR_RESPONSE: + code = int.from_bytes(payload[:4], "big", signed=False) + result["code"] = code + payload_size = int.from_bytes(payload[4:8], "big", signed=False) + payload_msg = payload[8:] + + if payload_msg is None: + return result + if message_compression == GZIP_COMPRESSION: + payload_msg = gzip.decompress(payload_msg) + if serialization_method == JSON_SERIALIZATION: + payload_msg = json.loads(payload_msg.decode("utf-8")) else: - yield data[offset:data_len], True + payload_msg = payload_msg.decode("utf-8") + result["payload_msg"] = payload_msg + result["payload_size"] = payload_size - async def speech_to_text( - self, opus_data: List[bytes], session_id: str - ) -> Tuple[Optional[str], Optional[str]]: - """将语音数据转换为文本""" + # 错误码处理 + if "code" in result: + error_code = result["code"] + error_message = "" - file_path = None - try: - # 合并所有opus数据包 - if self.audio_format == "pcm": - pcm_data = opus_data + if error_code == 1000: + error_message = "成功" + elif error_code == 1001: + error_message = "请求参数无效:请求参数缺失必需字段/字段值无效/重复请求" + elif error_code == 1002: + error_message = "无访问权限:token无效/过期/无权访问指定服务" + elif error_code == 1003: + error_message = "访问超频:当前appid访问QPS超出设定阈值" + elif error_code == 1004: + error_message = "访问超额:当前appid访问次数超出限制" + elif error_code == 1005: + error_message = "服务器繁忙:服务过载,无法处理当前请求" + elif error_code == 1010: + error_message = "音频过长:音频数据时长超出阈值" + elif error_code == 1011: + error_message = "音频过大:音频数据大小超出阈值" + elif error_code == 1012: + error_message = "音频格式无效:音频header有误/无法进行音频解码" + elif error_code == 1013: + error_message = "音频静音:音频未识别出任何文本结果" + elif error_code >= 1020 and error_code <= 1022: + error_message = "识别相关错误:需要重连" + if error_code == 1020: + error_message = "识别等待超时:等待下一包就绪超时" + elif error_code == 1021: + error_message = "识别处理超时:识别处理过程超时" + elif error_code == 1022: + error_message = "识别错误:识别过程中发生错误" else: - pcm_data = self.decode_opus(opus_data) - combined_pcm_data = b"".join(pcm_data) + error_message = "未知错误:未归类错误" - # 判断是否保存为WAV文件 - if self.delete_audio_file: - pass - else: - file_path = self.save_audio_to_file(pcm_data, session_id) + logger.bind(tag=TAG).debug( + f"ASR错误: {error_message} (错误码: {error_code})" + ) - # 直接使用PCM数据 - # 计算分段大小 (单声道, 16bit, 16kHz采样率) - size_per_sec = 1 * 2 * 16000 # nchannels * sampwidth * framerate - segment_size = int(size_per_sec * self.seg_duration / 1000) + # 如果是识别相关错误(>=1020),标记需要重连 + if error_code >= 1020: + result["need_reconnect"] = True - # 语音识别 - start_time = time.time() - text = await self._send_request(combined_pcm_data, segment_size) - if text: - logger.bind(tag=TAG).debug( - f"语音识别耗时: {time.time() - start_time:.3f}s | 结果: {text}" - ) - return text, file_path - return "", file_path - - except Exception as e: - logger.bind(tag=TAG).error(f"语音识别失败: {e}", exc_info=True) - return "", file_path + return result diff --git a/main/xiaozhi-server/core/providers/asr/dto/dto.py b/main/xiaozhi-server/core/providers/asr/dto/dto.py new file mode 100644 index 00000000..85b2d7b2 --- /dev/null +++ b/main/xiaozhi-server/core/providers/asr/dto/dto.py @@ -0,0 +1,9 @@ +from enum import Enum +from typing import Union, Optional + + +class InterfaceType(Enum): + # 接口类型 + STREAM = "STREAM" # 流式接口 + NON_STREAM = "NON_STREAM" # 非流式接口 + LOCAL = "LOCAL" # 本地服务 diff --git a/main/xiaozhi-server/core/providers/asr/fun_local.py b/main/xiaozhi-server/core/providers/asr/fun_local.py index e3b7fc83..8c7305c6 100644 --- a/main/xiaozhi-server/core/providers/asr/fun_local.py +++ b/main/xiaozhi-server/core/providers/asr/fun_local.py @@ -1,15 +1,14 @@ import time -import wave import os import sys import io from config.logger import setup_logging from typing import Optional, Tuple, List -import uuid from core.providers.asr.base import ASRProviderBase from funasr import AutoModel from funasr.utils.postprocess_utils import rich_transcription_postprocess import shutil +from core.providers.asr.dto.dto import InterfaceType TAG = __name__ logger = setup_logging() @@ -38,6 +37,7 @@ class CaptureOutput: class ASRProvider(ASRProviderBase): def __init__(self, config: dict, delete_audio_file: bool): super().__init__() + self.interface_type = InterfaceType.LOCAL self.model_dir = config.get("model_dir") self.output_dir = config.get("output_dir") # 修正配置键名 self.delete_audio_file = delete_audio_file diff --git a/main/xiaozhi-server/core/providers/asr/fun_server.py b/main/xiaozhi-server/core/providers/asr/fun_server.py index ff411340..60d5e425 100644 --- a/main/xiaozhi-server/core/providers/asr/fun_server.py +++ b/main/xiaozhi-server/core/providers/asr/fun_server.py @@ -1,11 +1,8 @@ from typing import Optional, Tuple, List -import opuslib_next from core.providers.asr.base import ASRProviderBase -import os +from core.providers.asr.dto.dto import InterfaceType import ssl import json -import uuid -import wave import websockets from config.logger import setup_logging import asyncio @@ -23,6 +20,7 @@ class ASRProvider(ASRProviderBase): :param delete_audio_file: Boolean to indicate whether to delete audio files after processing. """ super().__init__() + self.interface_type = InterfaceType.NON_STREAM self.host = config.get("host", "localhost") self.port = config.get("port", 10095) self.api_key = config.get("api_key", "none") diff --git a/main/xiaozhi-server/core/providers/asr/sherpa_onnx_local.py b/main/xiaozhi-server/core/providers/asr/sherpa_onnx_local.py index 24522d83..a27640dd 100644 --- a/main/xiaozhi-server/core/providers/asr/sherpa_onnx_local.py +++ b/main/xiaozhi-server/core/providers/asr/sherpa_onnx_local.py @@ -5,8 +5,7 @@ import sys import io from config.logger import setup_logging from typing import Optional, Tuple, List -import uuid -import opuslib_next +from core.providers.asr.dto.dto import InterfaceType from core.providers.asr.base import ASRProviderBase import numpy as np @@ -38,6 +37,7 @@ class CaptureOutput: class ASRProvider(ASRProviderBase): def __init__(self, config: dict, delete_audio_file: bool): super().__init__() + self.interface_type = InterfaceType.LOCAL self.model_dir = config.get("model_dir") self.output_dir = config.get("output_dir") self.delete_audio_file = delete_audio_file diff --git a/main/xiaozhi-server/core/providers/asr/tencent.py b/main/xiaozhi-server/core/providers/asr/tencent.py index 22a7ed94..03301568 100644 --- a/main/xiaozhi-server/core/providers/asr/tencent.py +++ b/main/xiaozhi-server/core/providers/asr/tencent.py @@ -5,9 +5,8 @@ import json import time from datetime import datetime, timezone import os -import uuid from typing import Optional, Tuple, List -import wave +from core.providers.asr.dto.dto import InterfaceType import requests from core.providers.asr.base import ASRProviderBase from config.logger import setup_logging @@ -23,6 +22,7 @@ class ASRProvider(ASRProviderBase): def __init__(self, config: dict, delete_audio_file: bool = True): super().__init__() + self.interface_type = InterfaceType.NON_STREAM self.secret_id = config.get("secret_id") self.secret_key = config.get("secret_key") self.output_dir = config.get("output_dir") diff --git a/main/xiaozhi-server/core/utils/modules_initialize.py b/main/xiaozhi-server/core/utils/modules_initialize.py new file mode 100644 index 00000000..f2e3968e --- /dev/null +++ b/main/xiaozhi-server/core/utils/modules_initialize.py @@ -0,0 +1,128 @@ +from typing import Dict, Any +from config.logger import setup_logging +from core.utils import tts, llm, intent, memory, vad, asr + +TAG = __name__ +logger = setup_logging() + + +def initialize_modules( + logger, + config: Dict[str, Any], + init_vad=False, + init_asr=False, + init_llm=False, + init_tts=False, + init_memory=False, + init_intent=False, +) -> Dict[str, Any]: + """ + 初始化所有模块组件 + + Args: + config: 配置字典 + + Returns: + Dict[str, Any]: 包含所有初始化后的模块的字典 + """ + modules = {} + + # 初始化TTS模块 + if init_tts: + select_tts_module = config["selected_module"]["TTS"] + modules["tts"] = initialize_tts(config) + logger.bind(tag=TAG).info(f"初始化组件: tts成功 {select_tts_module}") + + # 初始化LLM模块 + if init_llm: + select_llm_module = config["selected_module"]["LLM"] + llm_type = ( + select_llm_module + if "type" not in config["LLM"][select_llm_module] + else config["LLM"][select_llm_module]["type"] + ) + modules["llm"] = llm.create_instance( + llm_type, + config["LLM"][select_llm_module], + ) + logger.bind(tag=TAG).info(f"初始化组件: llm成功 {select_llm_module}") + + # 初始化Intent模块 + if init_intent: + select_intent_module = config["selected_module"]["Intent"] + intent_type = ( + select_intent_module + if "type" not in config["Intent"][select_intent_module] + else config["Intent"][select_intent_module]["type"] + ) + modules["intent"] = intent.create_instance( + intent_type, + config["Intent"][select_intent_module], + ) + logger.bind(tag=TAG).info(f"初始化组件: intent成功 {select_intent_module}") + + # 初始化Memory模块 + if init_memory: + select_memory_module = config["selected_module"]["Memory"] + memory_type = ( + select_memory_module + if "type" not in config["Memory"][select_memory_module] + else config["Memory"][select_memory_module]["type"] + ) + modules["memory"] = memory.create_instance( + memory_type, + config["Memory"][select_memory_module], + config.get("summaryMemory", None), + ) + logger.bind(tag=TAG).info(f"初始化组件: memory成功 {select_memory_module}") + + # 初始化VAD模块 + if init_vad: + select_vad_module = config["selected_module"]["VAD"] + vad_type = ( + select_vad_module + if "type" not in config["VAD"][select_vad_module] + else config["VAD"][select_vad_module]["type"] + ) + modules["vad"] = vad.create_instance( + vad_type, + config["VAD"][select_vad_module], + ) + logger.bind(tag=TAG).info(f"初始化组件: vad成功 {select_vad_module}") + + # 初始化ASR模块 + if init_asr: + select_asr_module = config["selected_module"]["ASR"] + modules["asr"] = initialize_asr(config) + logger.bind(tag=TAG).info(f"初始化组件: asr成功 {select_asr_module}") + return modules + + +def initialize_tts(config): + select_tts_module = config["selected_module"]["TTS"] + tts_type = ( + select_tts_module + if "type" not in config["TTS"][select_tts_module] + else config["TTS"][select_tts_module]["type"] + ) + new_tts = tts.create_instance( + tts_type, + config["TTS"][select_tts_module], + str(config.get("delete_audio", True)).lower() in ("true", "1", "yes"), + ) + return new_tts + + +def initialize_asr(config): + select_asr_module = config["selected_module"]["ASR"] + asr_type = ( + select_asr_module + if "type" not in config["ASR"][select_asr_module] + else config["ASR"][select_asr_module]["type"] + ) + new_asr = asr.create_instance( + asr_type, + config["ASR"][select_asr_module], + str(config.get("delete_audio", True)).lower() in ("true", "1", "yes"), + ) + return new_asr diff --git a/main/xiaozhi-server/core/utils/util.py b/main/xiaozhi-server/core/utils/util.py index 8f5ded4b..ff861aa9 100644 --- a/main/xiaozhi-server/core/utils/util.py +++ b/main/xiaozhi-server/core/utils/util.py @@ -7,8 +7,6 @@ import numpy as np import requests import opuslib_next from pydub import AudioSegment -from typing import Dict, Any -from core.utils import tts, llm, intent, memory, vad, asr import copy TAG = __name__ @@ -245,122 +243,6 @@ def extract_json_from_string(input_string): return None -def initialize_modules( - logger, - config: Dict[str, Any], - init_vad=False, - init_asr=False, - init_llm=False, - init_tts=False, - init_memory=False, - init_intent=False, -) -> Dict[str, Any]: - """ - 初始化所有模块组件 - - Args: - config: 配置字典 - - Returns: - Dict[str, Any]: 包含所有初始化后的模块的字典 - """ - modules = {} - - # 初始化TTS模块 - if init_tts: - select_tts_module = config["selected_module"]["TTS"] - modules["tts"] = initialize_tts(config) - logger.bind(tag=TAG).info(f"初始化组件: tts成功 {select_tts_module}") - - # 初始化LLM模块 - if init_llm: - select_llm_module = config["selected_module"]["LLM"] - llm_type = ( - select_llm_module - if "type" not in config["LLM"][select_llm_module] - else config["LLM"][select_llm_module]["type"] - ) - modules["llm"] = llm.create_instance( - llm_type, - config["LLM"][select_llm_module], - ) - logger.bind(tag=TAG).info(f"初始化组件: llm成功 {select_llm_module}") - - # 初始化Intent模块 - if init_intent: - select_intent_module = config["selected_module"]["Intent"] - intent_type = ( - select_intent_module - if "type" not in config["Intent"][select_intent_module] - else config["Intent"][select_intent_module]["type"] - ) - modules["intent"] = intent.create_instance( - intent_type, - config["Intent"][select_intent_module], - ) - logger.bind(tag=TAG).info(f"初始化组件: intent成功 {select_intent_module}") - - # 初始化Memory模块 - if init_memory: - select_memory_module = config["selected_module"]["Memory"] - memory_type = ( - select_memory_module - if "type" not in config["Memory"][select_memory_module] - else config["Memory"][select_memory_module]["type"] - ) - modules["memory"] = memory.create_instance( - memory_type, - config["Memory"][select_memory_module], - config.get("summaryMemory", None), - ) - logger.bind(tag=TAG).info(f"初始化组件: memory成功 {select_memory_module}") - - # 初始化VAD模块 - if init_vad: - select_vad_module = config["selected_module"]["VAD"] - vad_type = ( - select_vad_module - if "type" not in config["VAD"][select_vad_module] - else config["VAD"][select_vad_module]["type"] - ) - modules["vad"] = vad.create_instance( - vad_type, - config["VAD"][select_vad_module], - ) - logger.bind(tag=TAG).info(f"初始化组件: vad成功 {select_vad_module}") - - # 初始化ASR模块 - if init_asr: - select_asr_module = config["selected_module"]["ASR"] - asr_type = ( - select_asr_module - if "type" not in config["ASR"][select_asr_module] - else config["ASR"][select_asr_module]["type"] - ) - modules["asr"] = asr.create_instance( - asr_type, - config["ASR"][select_asr_module], - str(config.get("delete_audio", True)).lower() in ("true", "1", "yes"), - ) - logger.bind(tag=TAG).info(f"初始化组件: asr成功 {select_asr_module}") - return modules - - -def initialize_tts(config): - select_tts_module = config["selected_module"]["TTS"] - tts_type = ( - select_tts_module - if "type" not in config["TTS"][select_tts_module] - else config["TTS"][select_tts_module]["type"] - ) - new_tts = tts.create_instance( - tts_type, - config["TTS"][select_tts_module], - str(config.get("delete_audio", True)).lower() in ("true", "1", "yes"), - ) - return new_tts - - def analyze_emotion(text): """ 分析文本情感并返回对应的emoji名称(支持中英文) diff --git a/main/xiaozhi-server/core/websocket_server.py b/main/xiaozhi-server/core/websocket_server.py index e60f0c87..1a1ba4c0 100644 --- a/main/xiaozhi-server/core/websocket_server.py +++ b/main/xiaozhi-server/core/websocket_server.py @@ -2,8 +2,9 @@ import asyncio import websockets from config.logger import setup_logging from core.connection import ConnectionHandler -from core.utils.util import initialize_modules, check_vad_update, check_asr_update from config.config_loader import get_config_from_api +from core.utils.modules_initialize import initialize_modules +from core.utils.util import check_vad_update, check_asr_update TAG = __name__ From 8f48e9ac0c014567aa105098eb13c56955652810 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Fri, 30 May 2025 02:05:34 +0800 Subject: [PATCH 3/6] =?UTF-8?q?update:=E8=B0=83=E9=80=9A=E6=B5=81=E5=BC=8F?= =?UTF-8?q?ASR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/app.py | 10 +++++----- .../xiaozhi-server/core/providers/asr/base.py | 20 +++++++++---------- .../core/providers/asr/doubao.py | 2 +- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/main/xiaozhi-server/app.py b/main/xiaozhi-server/app.py index 63849c58..1ae61cbb 100644 --- a/main/xiaozhi-server/app.py +++ b/main/xiaozhi-server/app.py @@ -1,13 +1,13 @@ -import asyncio import sys import signal +import asyncio +from aioconsole import ainput from config.settings import load_config -from core.websocket_server import WebSocketServer -from core.ota_server import SimpleOtaServer -from core.utils.util import check_ffmpeg_installed from config.logger import setup_logging from core.utils.util import get_local_ip -from aioconsole import ainput +from core.ota_server import SimpleOtaServer +from core.websocket_server import WebSocketServer +from core.utils.util import check_ffmpeg_installed TAG = __name__ logger = setup_logging() diff --git a/main/xiaozhi-server/core/providers/asr/base.py b/main/xiaozhi-server/core/providers/asr/base.py index d3ce5ee0..1a3a384d 100644 --- a/main/xiaozhi-server/core/providers/asr/base.py +++ b/main/xiaozhi-server/core/providers/asr/base.py @@ -38,34 +38,32 @@ class ASRProviderBase(ABC): else: have_voice = self.conn.client_have_voice # 如果本次没有声音,本段也没声音,就把声音丢弃了 + self.conn.asr_audio.append(audio) if have_voice == False and self.conn.client_have_voice == False: - self.conn.asr_audio.append(audio) self.conn.asr_audio = self.conn.asr_audio[-10:] return # 如果本段有声音,且已经停止了 if self.conn.client_voice_stop: + asr_audio_task = copy.deepcopy(self.conn.asr_audio) + self.conn.asr_audio.clear() self.conn.client_abort = False # 音频太短了,无法识别 - if len(self.conn.asr_audio) < 15: - self.conn.asr_audio.clear() - self.conn.reset_vad_states() - else: - await self.handle_voice_stop() + self.conn.reset_vad_states() + if len(asr_audio_task) > 15: + await self.handle_voice_stop(asr_audio_task) # 处理语音停止 - async def handle_voice_stop(self): + async def handle_voice_stop(self, asr_audio_task): raw_text, _ = await self.speech_to_text( - self.conn.asr_audio, self.conn.session_id + asr_audio_task, self.conn.session_id ) # 确保ASR模块返回原始文本 self.conn.logger.bind(tag=TAG).info(f"识别文本: {raw_text}") text_len, _ = remove_punctuation_and_length(raw_text) if text_len > 0: # 使用自定义模块进行上报 await startToChat(self.conn, raw_text) - enqueue_asr_report(self.conn, raw_text, copy.deepcopy(self.conn.asr_audio)) - self.conn.asr_audio.clear() - self.conn.reset_vad_states() + enqueue_asr_report(self.conn, raw_text, asr_audio_task) def save_audio_to_file(self, pcm_data: List[bytes], session_id: str) -> str: """PCM数据保存为WAV文件""" diff --git a/main/xiaozhi-server/core/providers/asr/doubao.py b/main/xiaozhi-server/core/providers/asr/doubao.py index d738f508..96abdff3 100644 --- a/main/xiaozhi-server/core/providers/asr/doubao.py +++ b/main/xiaozhi-server/core/providers/asr/doubao.py @@ -289,7 +289,7 @@ class ASRProvider(ASRProviderBase): for utterance in utterances: if utterance.get("definite", False): self.text = utterance["text"] - await self.handle_voice_stop() + await self.handle_voice_stop(None) break except websockets.ConnectionClosed: logger.bind(tag=TAG).debug("ASR服务连接已关闭,准备重连...") From a0b0a0e1e8c8a34c7069a00d2a7f966bfaca62f7 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Fri, 30 May 2025 02:16:39 +0800 Subject: [PATCH 4/6] =?UTF-8?q?update:=E4=BC=98=E5=8C=96=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E6=89=93=E5=8D=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/core/handle/reportHandle.py | 3 ++- main/xiaozhi-server/core/providers/asr/doubao.py | 2 -- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/main/xiaozhi-server/core/handle/reportHandle.py b/main/xiaozhi-server/core/handle/reportHandle.py index b1214fd5..7b30f79c 100644 --- a/main/xiaozhi-server/core/handle/reportHandle.py +++ b/main/xiaozhi-server/core/handle/reportHandle.py @@ -8,6 +8,7 @@ TTS上报功能已集成到ConnectionHandler类中。 具体实现请参考core/connection.py中的相关代码。 """ + import time import opuslib_next @@ -145,4 +146,4 @@ def enqueue_asr_report(conn, text, opus_data): f"ASR数据已加入上报队列: {conn.device_id}, 不上报音频" ) except Exception as e: - conn.logger.bind(tag=TAG).error(f"加入ASR上报队列失败: {text}, {e}") + conn.logger.bind(tag=TAG).debug(f"加入ASR上报队列失败: {text}, {e}") diff --git a/main/xiaozhi-server/core/providers/asr/doubao.py b/main/xiaozhi-server/core/providers/asr/doubao.py index 96abdff3..9ef1d411 100644 --- a/main/xiaozhi-server/core/providers/asr/doubao.py +++ b/main/xiaozhi-server/core/providers/asr/doubao.py @@ -107,7 +107,6 @@ class ASRProvider(ASRProviderBase): # 等待初始化响应 try: init_res = await self.asr_ws.recv() - logger.bind(tag=TAG).debug(f"收到原始响应: {init_res}") result = self.parse_response(init_res) logger.bind(tag=TAG).info(f"ASR服务初始化响应: {result}") except Exception as e: @@ -328,7 +327,6 @@ class ASRProvider(ASRProviderBase): self.retry_delay ) # 继续等待,以便后续重试 except Exception as e: - logger.bind(tag=TAG).error(f"处理ASR结果时发生错误: {e}") if not self.conn.stop_event.is_set(): await asyncio.sleep(2) # 增加重试延迟 except Exception as e: From c4a240cb045b280e542ef3446e805b4e1898edbe Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Fri, 30 May 2025 09:32:11 +0800 Subject: [PATCH 5/6] =?UTF-8?q?update:=E6=B7=BB=E5=8A=A0=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 29 ++++++++++++++---- .../xiaozhi/common/constant/Constant.java | 2 +- main/xiaozhi-server/config/logger.py | 30 ++++++++++++++----- 3 files changed, 48 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 27c8c759..62a3f78b 100644 --- a/README.md +++ b/README.md @@ -141,11 +141,10 @@ 本项目提供两种部署方式,请根据您的具体需求选择: #### 🚀 部署方式选择 - -| 部署方式 | 特点 | 适用场景 | Docker部署文档 | 源码部署文档 | 视频教程 | +| 部署方式 | 特点 | 适用场景 | 部署文档 | 配置要求 | 视频教程 | |---------|------|---------|---------|---------|---------| -| **最简化安装** | 智能对话、IOT功能,数据存储在配置文件 | 低配置环境,无需数据库 | [Docker只运行Server](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) | [本地源码只运行Server](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)| - | -| **全模块安装** | 智能对话、IOT、OTA、智控台,数据存储在数据库 | 完整功能体验 |[Docker运行全模块](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) | [本地源码运行全模块](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) | [本地源码视频教程](https://www.bilibili.com/video/BV1wBJhz4Ewe) | +| **最简化安装** | 智能对话、IOT功能,数据存储在配置文件 | 低配置环境,无需数据库 | [Docker版](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [源码部署](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)| 如果使用`FunASR`要2核4G,如果全API,要2核2G | - | +| **全模块安装** | 智能对话、IOT、OTA、智控台,数据存储在数据库 | 完整功能体验 |[Docker版](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [源码部署](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) | 如果使用`FunASR`要4核8G,如果全API,要2核4G| [本地源码视频教程](https://www.bilibili.com/video/BV1wBJhz4Ewe) | > 💡 提示:以下是按最新代码部署后的测试平台,有需要可烧录测试,并发为6个,每天会清空数据 @@ -158,9 +157,29 @@ OTA接口地址: https://2662r3426b.vicp.fun/xiaozhi/ota/ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/ ``` +#### 🚩 配置说明和推荐 + +本项目默认的配置是`入门全免费`设置,如果想效果更优,推荐使用`全流式配置`。 + +本项目自`0.5.2`版本,已支持整个生命周期全流式,相比`0.5`版本以前,响应速度提升约`2.5秒` + +> [!Note] +> 1、输入流式(自有流式) +> 2、语音识别流式(DoubaoASR(火山流式语音识别)) +> 3、大模型流式(所有支持openai、ollma接口的大模型) +> 4、语音合成双流式(HuoshanDoubleStreamTTS(火山双流式语音合成)) +> 5、输出音频流式(自有流式) + +| 模块名称 | 入门全免费设置 | 全流式配置 | +|---------|---------|------| +| ASR(语音识别) | FunASR(本地) | ✅DoubaoASR(火山流式语音识别) | +| LLM(大模型) | ChatGLMLLM(智谱glm-4-flash) | ✅DoubaoLLM(火山doubao-1-5-pro-32k-250115) | +| TTS(语音合成) | EdgeTTS(微软语音) | ✅HuoshanDoubleStreamTTS(火山双流式语音合成) | +| Intent(意图识别) | function_call(函数调用) | ✅function_call(函数调用) | +| Memory(记忆功能) | mem_local_short(本地短期记忆) | ✅mem_local_short(本地短期记忆) | + --- ## 功能清单 ✨ - ### 已实现 ✅ | 功能模块 | 描述 | diff --git a/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java b/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java index abac385b..2e3174af 100644 --- a/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java +++ b/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java @@ -227,7 +227,7 @@ public interface Constant { /** * 版本号 */ - public static final String VERSION = "0.5.1"; + public static final String VERSION = "0.5.2"; /** * 无效固件URL diff --git a/main/xiaozhi-server/config/logger.py b/main/xiaozhi-server/config/logger.py index e5fec630..5f107f5d 100644 --- a/main/xiaozhi-server/config/logger.py +++ b/main/xiaozhi-server/config/logger.py @@ -4,7 +4,7 @@ from loguru import logger from config.config_loader import load_config from config.settings import check_config_file -SERVER_VERSION = "0.5.1" +SERVER_VERSION = "0.5.2" _logger_initialized = False @@ -48,7 +48,11 @@ def setup_logging(): # 第一次初始化时配置日志 if not _logger_initialized: - logger.configure(extra={"selected_module": log_config.get("selected_module", "00000000000000")}) # 新增配置 + logger.configure( + extra={ + "selected_module": log_config.get("selected_module", "00000000000000") + } + ) # 新增配置 log_format = log_config.get( "log_format", "{time:YYMMDD HH:mm:ss}[{version}_{extra[selected_module]}][{extra[tag]}]-{level}-{message}", @@ -62,7 +66,9 @@ def setup_logging(): log_format = log_format.replace("{version}", SERVER_VERSION) log_format = log_format.replace("{selected_module}", selected_module_str) log_format_file = log_format_file.replace("{version}", SERVER_VERSION) - log_format_file = log_format_file.replace("{selected_module}", selected_module_str) + log_format_file = log_format_file.replace( + "{selected_module}", selected_module_str + ) log_level = log_config.get("log_level", "INFO") log_dir = log_config.get("log_dir", "tmp") @@ -116,15 +122,25 @@ def update_module_string(selected_module_str): log_format = log_format.replace("{version}", SERVER_VERSION) log_format = log_format.replace("{selected_module}", selected_module_str) log_format_file = log_format_file.replace("{version}", SERVER_VERSION) - log_format_file = log_format_file.replace("{selected_module}", selected_module_str) + log_format_file = log_format_file.replace( + "{selected_module}", selected_module_str + ) logger.remove() - logger.add(sys.stdout, format=log_format, level=log_config.get("log_level", "INFO"), filter=formatter) logger.add( - os.path.join(log_config.get("log_dir", "tmp"), log_config.get("log_file", "server.log")), + sys.stdout, + format=log_format, + level=log_config.get("log_level", "INFO"), + filter=formatter, + ) + logger.add( + os.path.join( + log_config.get("log_dir", "tmp"), + log_config.get("log_file", "server.log"), + ), format=log_format_file, level=log_config.get("log_level", "INFO"), - filter=formatter + filter=formatter, ) except Exception as e: From 2fd0bb4912a22985cdfa06049f29be560d685349 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Fri, 30 May 2025 09:37:13 +0800 Subject: [PATCH 6/6] =?UTF-8?q?update:=E4=BC=98=E5=8C=96=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 62a3f78b..a94c89f2 100644 --- a/README.md +++ b/README.md @@ -158,17 +158,11 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/ ``` #### 🚩 配置说明和推荐 - -本项目默认的配置是`入门全免费`设置,如果想效果更优,推荐使用`全流式配置`。 - -本项目自`0.5.2`版本,已支持整个生命周期全流式,相比`0.5`版本以前,响应速度提升约`2.5秒` - > [!Note] -> 1、输入流式(自有流式) -> 2、语音识别流式(DoubaoASR(火山流式语音识别)) -> 3、大模型流式(所有支持openai、ollma接口的大模型) -> 4、语音合成双流式(HuoshanDoubleStreamTTS(火山双流式语音合成)) -> 5、输出音频流式(自有流式) +> 本项目默认的配置是`入门全免费`设置,如果想效果更优,推荐使用`全流式配置`。 +> +> 本项目自`0.5.2`版本,已支持整个生命周期全流式,相比`0.5`版本以前,响应速度提升约`2.5秒` + | 模块名称 | 入门全免费设置 | 全流式配置 | |---------|---------|------|