From eaade698fb2c5c1b3d41bccdf8d2d30e5f6253b0 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Sat, 30 Aug 2025 22:59:21 +0800 Subject: [PATCH 01/13] =?UTF-8?q?update:=E6=B5=81=E5=BC=8F=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E6=9A=82=E4=B8=8D=E7=A8=B3=E5=AE=9A=EF=BC=8C=E5=85=88?= =?UTF-8?q?=E5=9B=9E=E6=BB=9A=E5=88=B0=E6=97=A9=E6=9C=9F=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/config.yaml | 4 + .../xiaozhi-server/core/handle/helloHandle.py | 112 +++++++++- .../core/handle/intentHandler.py | 14 +- .../core/handle/receiveAudioHandle.py | 51 +++-- .../core/handle/sendAudioHandle.py | 89 ++++---- main/xiaozhi-server/core/handle/textHandle.py | 29 ++- .../xiaozhi-server/core/providers/asr/base.py | 152 ++++++-------- .../core/providers/tts/aliyun_stream.py | 195 +++++++++++++++++- .../xiaozhi-server/core/providers/tts/base.py | 140 ++++++------- .../providers/tts/huoshan_double_stream.py | 144 ++++++++++++- .../core/providers/tts/index_stream.py | 111 ++++++++-- .../core/providers/tts/linkerai.py | 135 ++++++++++-- .../core/providers/tts/paddle_speech.py | 30 +-- .../core/providers/vad/silero.py | 6 +- .../core/utils/opus_encoder_utils.py | 17 +- main/xiaozhi-server/core/utils/p3.py | 37 +++- main/xiaozhi-server/core/utils/util.py | 78 ++++--- main/xiaozhi-server/core/utils/wakeup_word.py | 140 +++++++++++++ .../plugins_func/functions/play_music.py | 1 + 19 files changed, 1116 insertions(+), 369 deletions(-) create mode 100644 main/xiaozhi-server/core/utils/wakeup_word.py diff --git a/main/xiaozhi-server/config.yaml b/main/xiaozhi-server/config.yaml index efcca44f..c0aa3f93 100644 --- a/main/xiaozhi-server/config.yaml +++ b/main/xiaozhi-server/config.yaml @@ -59,6 +59,10 @@ log: delete_audio: true # 没有语音输入多久后断开连接(秒),默认2分钟,即120秒 close_connection_no_voice_time: 120 +# TTS请求超时时间(秒) +tts_timeout: 10 +# 开启唤醒词加速 +enable_wakeup_words_response_cache: true # 开场是否回复唤醒词 enable_greeting: true # 说完话是否开启提示音 diff --git a/main/xiaozhi-server/core/handle/helloHandle.py b/main/xiaozhi-server/core/handle/helloHandle.py index 8147db1a..e4e836b4 100644 --- a/main/xiaozhi-server/core/handle/helloHandle.py +++ b/main/xiaozhi-server/core/handle/helloHandle.py @@ -1,13 +1,33 @@ +import time import json +import random import asyncio +from core.utils.dialogue import Message +from core.utils.util import audio_to_data +from core.handle.sendAudioHandle import sendAudioMessage, send_stt_message +from core.utils.util import remove_punctuation_and_length, opus_datas_to_wav_bytes +from core.providers.tts.dto.dto import ContentType, SentenceType from core.providers.tools.device_mcp import ( MCPClient, send_mcp_initialize_message, send_mcp_tools_list_request, ) +from core.utils.wakeup_word import WakeupWordsConfig TAG = __name__ +WAKEUP_CONFIG = { + "refresh_time": 5, + "words": ["你好", "你好啊", "嘿,你好", "嗨"], +} + +# 创建全局的唤醒词配置管理器 +wakeup_words_config = WakeupWordsConfig() + +# 用于防止并发调用wakeupWordsResponse的锁 +_wakeup_response_lock = asyncio.Lock() + + async def handleHelloMessage(conn, msg_json): """处理hello消息""" audio_params = msg_json.get("audio_params") @@ -28,4 +48,94 @@ async def handleHelloMessage(conn, msg_json): # 发送mcp消息,获取tools列表 asyncio.create_task(send_mcp_tools_list_request(conn)) - await conn.websocket.send(json.dumps(conn.welcome_msg)) \ No newline at end of file + await conn.websocket.send(json.dumps(conn.welcome_msg)) + + +async def checkWakeupWords(conn, text): + enable_wakeup_words_response_cache = conn.config[ + "enable_wakeup_words_response_cache" + ] + + if not enable_wakeup_words_response_cache or not conn.tts: + return False + + _, filtered_text = remove_punctuation_and_length(text) + if filtered_text not in conn.config.get("wakeup_words"): + return False + + conn.just_woken_up = True + await send_stt_message(conn, text) + + # 获取当前音色 + voice = getattr(conn.tts, "voice", "default") + if not voice: + voice = "default" + + # 获取唤醒词回复配置 + response = wakeup_words_config.get_wakeup_response(voice) + if not response or not response.get("file_path"): + response = { + "voice": "default", + "file_path": "config/assets/wakeup_words.wav", + "time": 0, + "text": "哈啰啊,我是小智啦,声音好听的台湾女孩一枚,超开心认识你耶,最近在忙啥,别忘了给我来点有趣的料哦,我超爱听八卦的啦", + } + + # 播放唤醒词回复 + conn.client_abort = False + opus_packets, _ = audio_to_data(response.get("file_path")) + + conn.logger.bind(tag=TAG).info(f"播放唤醒词回复: {response.get('text')}") + await sendAudioMessage(conn, SentenceType.FIRST, opus_packets, response.get("text")) + await sendAudioMessage(conn, SentenceType.LAST, [], None) + + # 补充对话 + conn.dialogue.put(Message(role="assistant", content=response.get("text"))) + + # 检查是否需要更新唤醒词回复 + if time.time() - response.get("time", 0) > WAKEUP_CONFIG["refresh_time"]: + if not _wakeup_response_lock.locked(): + asyncio.create_task(wakeupWordsResponse(conn)) + return True + + +async def wakeupWordsResponse(conn): + if not conn.tts or not conn.llm or not conn.llm.response_no_stream: + return + + try: + # 尝试获取锁,如果获取不到就返回 + if not await _wakeup_response_lock.acquire(): + return + + # 生成唤醒词回复 + wakeup_word = random.choice(WAKEUP_CONFIG["words"]) + question = ( + "此刻用户正在和你说```" + + wakeup_word + + "```。\n请你根据以上用户的内容进行20-30字回复。要符合系统设置的角色情感和态度,不要像机器人一样说话。\n" + + "请勿对这条内容本身进行任何解释和回应,请勿返回表情符号,仅返回对用户的内容的回复。" + ) + + result = conn.llm.response_no_stream(conn.config["prompt"], question) + if not result or len(result) == 0: + return + + # 生成TTS音频 + tts_result = await asyncio.to_thread(conn.tts.to_tts, result) + if not tts_result: + return + + # 获取当前音色 + voice = getattr(conn.tts, "voice", "default") + + wav_bytes = opus_datas_to_wav_bytes(tts_result, sample_rate=16000) + file_path = wakeup_words_config.generate_file_path(voice) + with open(file_path, "wb") as f: + f.write(wav_bytes) + # 更新配置 + wakeup_words_config.update_wakeup_response(voice, file_path, result) + finally: + # 确保在任何情况下都释放锁 + if _wakeup_response_lock.locked(): + _wakeup_response_lock.release() diff --git a/main/xiaozhi-server/core/handle/intentHandler.py b/main/xiaozhi-server/core/handle/intentHandler.py index 2ddf03d6..81911f55 100644 --- a/main/xiaozhi-server/core/handle/intentHandler.py +++ b/main/xiaozhi-server/core/handle/intentHandler.py @@ -1,11 +1,12 @@ import json -import uuid import asyncio -from core.utils.dialogue import Message -from core.providers.tts.dto.dto import ContentType -from plugins_func.register import Action, ActionResponse +import uuid from core.handle.sendAudioHandle import send_stt_message +from core.handle.helloHandle import checkWakeupWords from core.utils.util import remove_punctuation_and_length +from core.providers.tts.dto.dto import ContentType +from core.utils.dialogue import Message +from plugins_func.register import Action, ActionResponse from core.providers.tts.dto.dto import TTSMessageDTO, SentenceType TAG = __name__ @@ -23,9 +24,12 @@ async def handle_user_intent(conn, text): pass # 检查是否有明确的退出命令 - _, filtered_text = remove_punctuation_and_length(text) + filtered_text = remove_punctuation_and_length(text)[1] if await check_direct_exit(conn, filtered_text): return True + # 检查是否是唤醒词 + if await checkWakeupWords(conn, filtered_text): + return True if conn.intent_type == "function_call": # 使用支持function calling的聊天方法,不再进行意图分析 diff --git a/main/xiaozhi-server/core/handle/receiveAudioHandle.py b/main/xiaozhi-server/core/handle/receiveAudioHandle.py index 15a9e434..e6f39632 100644 --- a/main/xiaozhi-server/core/handle/receiveAudioHandle.py +++ b/main/xiaozhi-server/core/handle/receiveAudioHandle.py @@ -1,21 +1,28 @@ -import time -import json -from core.handle.abortHandle import handleAbortMessage +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.utils.util import play_audio_frames, play_audio_response -from core.handle.sendAudioHandle import send_stt_message, SentenceType +from core.handle.abortHandle import handleAbortMessage +import time +import asyncio +import json +from core.handle.sendAudioHandle import SentenceType +from core.utils.util import audio_to_data TAG = __name__ async def handleAudioMessage(conn, audio): - # 检查是否在唤醒处理锁定期内 - if getattr(conn, 'wakeup_processing_lock', 0) > time.monotonic(): - return - # 当前片段是否有人说话 have_voice = conn.vad.is_vad(conn, audio) + # 如果设备刚刚被唤醒,短暂忽略VAD检测 + if have_voice and hasattr(conn, "just_woken_up") and conn.just_woken_up: + have_voice = False + # 设置一个短暂延迟后恢复VAD检测 + conn.asr_audio.clear() + if not hasattr(conn, "vad_resume_task") or conn.vad_resume_task.done(): + conn.vad_resume_task = asyncio.create_task(resume_vad_detection(conn)) + return + if have_voice: if conn.client_is_speaking: await handleAbortMessage(conn) @@ -24,11 +31,18 @@ async def handleAudioMessage(conn, audio): # 接收音频 await conn.asr.receive_audio(conn, audio, have_voice) + +async def resume_vad_detection(conn): + # 等待2秒后恢复VAD检测 + await asyncio.sleep(1) + conn.just_woken_up = False + + async def startToChat(conn, text): # 检查输入是否是JSON格式(包含说话人信息) speaker_name = None actual_text = text - + try: # 尝试解析JSON格式的输入 if text.strip().startswith('{') and text.strip().endswith('}'): @@ -37,13 +51,13 @@ async def startToChat(conn, text): speaker_name = data['speaker'] actual_text = data['content'] conn.logger.bind(tag=TAG).info(f"解析到说话人信息: {speaker_name}") - + # 直接使用JSON格式的文本,不解析 actual_text = text except (json.JSONDecodeError, KeyError): # 如果解析失败,继续使用原始文本 pass - + # 保存说话人信息到连接对象 if speaker_name: conn.current_speaker = speaker_name @@ -107,7 +121,8 @@ async def max_out_size(conn): text = "不好意思,我现在有点事情要忙,明天这个时候我们再聊,约好了哦!明天不见不散,拜拜!" await send_stt_message(conn, text) file_path = "config/assets/max_output_size.wav" - play_audio_response(conn, {"text": text, "file_path": file_path}) + opus_packets, _ = audio_to_data(file_path) + conn.tts.tts_audio_queue.put((SentenceType.LAST, opus_packets, text)) conn.close_after_chat = True @@ -125,15 +140,16 @@ async def check_bind_device(conn): # 播放提示音 music_path = "config/assets/bind_code.wav" - conn.tts.tts_audio_queue.put((SentenceType.FIRST, [], text)) - play_audio_frames(conn, music_path) + opus_packets, _ = audio_to_data(music_path) + conn.tts.tts_audio_queue.put((SentenceType.FIRST, opus_packets, text)) # 逐个播放数字 for i in range(6): # 确保只播放6位数字 try: digit = conn.bind_code[i] num_path = f"config/assets/bind_code/{digit}.wav" - play_audio_frames(conn, num_path) + num_packets, _ = audio_to_data(num_path) + conn.tts.tts_audio_queue.put((SentenceType.MIDDLE, num_packets, None)) except Exception as e: conn.logger.bind(tag=TAG).error(f"播放数字音频失败: {e}") continue @@ -142,4 +158,5 @@ async def check_bind_device(conn): text = f"没有找到该设备的版本信息,请正确配置 OTA地址,然后重新编译固件。" await send_stt_message(conn, text) music_path = "config/assets/bind_not_found.wav" - play_audio_response(conn, {"text": text, "file_path": music_path}) + opus_packets, _ = audio_to_data(music_path) + conn.tts.tts_audio_queue.put((SentenceType.LAST, opus_packets, text)) diff --git a/main/xiaozhi-server/core/handle/sendAudioHandle.py b/main/xiaozhi-server/core/handle/sendAudioHandle.py index c61b0d0d..0ca36e1e 100644 --- a/main/xiaozhi-server/core/handle/sendAudioHandle.py +++ b/main/xiaozhi-server/core/handle/sendAudioHandle.py @@ -1,25 +1,25 @@ import json -import time import asyncio -from core.utils import textUtils +import time from core.providers.tts.dto.dto import SentenceType +from core.utils import textUtils TAG = __name__ async def sendAudioMessage(conn, sentenceType, audios, text): + # 发送句子开始消息 + conn.logger.bind(tag=TAG).info(f"发送音频消息: {sentenceType}, {text}") + + pre_buffer = False if conn.tts.tts_audio_first_sentence: conn.logger.bind(tag=TAG).info(f"发送第一段语音: {text}") conn.tts.tts_audio_first_sentence = False - await send_tts_message(conn, "start", None) + pre_buffer = True - if sentenceType == SentenceType.FIRST: - await send_tts_message(conn, "sentence_start", text) + await send_tts_message(conn, "sentence_start", text) - await sendAudio(conn, audios) - # 发送句子开始消息 - if sentenceType is not SentenceType.MIDDLE: - conn.logger.bind(tag=TAG).info(f"发送音频消息: {sentenceType}, {text}") + await sendAudio(conn, audios, pre_buffer) # 发送结束消息(如果是最后一个文本) if conn.llm_finish_task and sentenceType == SentenceType.LAST: @@ -30,59 +30,45 @@ async def sendAudioMessage(conn, sentenceType, audios, text): # 播放音频 -async def sendAudio(conn, audios, pre_buffer=False): - """ - 发送单个opus包,支持流控 - Args: - conn: 连接对象 - opus_packet: 单个opus数据包 - pre_buffer: 快速发送音频 - """ +async def sendAudio(conn, audios, pre_buffer=True): if audios is None or len(audios) == 0: return + # 流控参数优化 + frame_duration = 60 # 帧时长(毫秒),匹配 Opus 编码 + start_time = time.perf_counter() + play_position = 0 - if isinstance(audios, bytes): + # 仅当第一句话时执行预缓冲 + if pre_buffer: + pre_buffer_frames = min(3, len(audios)) + for i in range(pre_buffer_frames): + await conn.websocket.send(audios[i]) + remaining_audios = audios[pre_buffer_frames:] + else: + remaining_audios = audios + + # 播放剩余音频帧 + for opus_packet in remaining_audios: if conn.client_abort: - return - - # 短音频直接发送(例如:提示音) - if pre_buffer: - await conn.websocket.send(audios) - return + break + # 重置没有声音的状态 conn.last_activity_time = time.time() * 1000 - # 获取或初始化流控状态 - if not hasattr(conn, "audio_flow_control"): - conn.audio_flow_control = { - "last_send_time": 0, - "packet_count": 0, - "start_time": time.perf_counter(), - } - - frame_duration=60 - flow_control = conn.audio_flow_control - current_time = time.perf_counter() # 计算预期发送时间 - expected_time = flow_control["start_time"] + ( - flow_control["packet_count"] * frame_duration / 1000 - ) + expected_time = start_time + (play_position / 1000) + current_time = time.perf_counter() delay = expected_time - current_time if delay > 0: await asyncio.sleep(delay) - # 发送数据包 - await conn.websocket.send(audios) + await conn.websocket.send(opus_packet) - # 更新流控状态 - flow_control["packet_count"] += 1 - flow_control["last_send_time"] = time.perf_counter() + play_position += frame_duration async def send_tts_message(conn, state, text=None): """发送 TTS 状态消息""" - if text is None and state == "sentence_start": - return message = {"type": "tts", "state": state, "session_id": conn.session_id} if text is not None: message["text"] = textUtils.check_emoji(text) @@ -95,12 +81,8 @@ async def send_tts_message(conn, state, text=None): stop_tts_notify_voice = conn.config.get( "stop_tts_notify_voice", "config/assets/tts_notify.mp3" ) - conn.tts.audio_to_opus_data_stream( - stop_tts_notify_voice, - callback=lambda audio_data: asyncio.run_coroutine_threadsafe( - sendAudio(conn, audio_data, True), conn.loop - ), - ) + audios, _ = conn.tts.audio_to_opus_data(stop_tts_notify_voice) + await sendAudio(conn, audios) # 清除服务端讲话状态 conn.clearSpeakStatus() @@ -109,17 +91,18 @@ async def send_tts_message(conn, state, text=None): async def send_stt_message(conn, text): - """发送 STT 状态消息""" 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 状态消息""" + # 解析JSON格式,提取实际的用户说话内容 display_text = text try: # 尝试解析JSON格式 - if text.strip().startswith("{") and text.strip().endswith("}"): + if text.strip().startswith('{') and text.strip().endswith('}'): parsed_data = json.loads(text) if isinstance(parsed_data, dict) and "content" in parsed_data: # 如果是包含说话人信息的JSON格式,只显示content部分 diff --git a/main/xiaozhi-server/core/handle/textHandle.py b/main/xiaozhi-server/core/handle/textHandle.py index c1e8f6be..1bcf4545 100644 --- a/main/xiaozhi-server/core/handle/textHandle.py +++ b/main/xiaozhi-server/core/handle/textHandle.py @@ -1,12 +1,14 @@ import json -import asyncio -from core.utils.util import filter_sensitive_info +import time from core.handle.abortHandle import handleAbortMessage from core.handle.helloHandle import handleHelloMessage -from core.handle.receiveAudioHandle import handleAudioMessage from core.providers.tools.device_mcp import handle_mcp_message +from core.utils.util import remove_punctuation_and_length, filter_sensitive_info +from core.handle.receiveAudioHandle import startToChat, handleAudioMessage from core.handle.sendAudioHandle import send_stt_message, send_tts_message from core.providers.tools.device_iot import handleIotDescriptors, handleIotStatus +from core.handle.reportHandle import enqueue_asr_report +import asyncio TAG = __name__ @@ -44,9 +46,14 @@ async def handleTextMessage(conn, message): conn.client_have_voice = False conn.asr_audio.clear() if "text" in msg_json: - original_text = msg_json["text"] # 保留设备上传的文本 + conn.last_activity_time = time.time() * 1000 + original_text = msg_json["text"] # 保留原始文本 + filtered_len, filtered_text = remove_punctuation_and_length( + original_text + ) + # 识别是否是唤醒词 - is_wakeup_words = original_text in conn.config.get("wakeup_words") + is_wakeup_words = filtered_text in conn.config.get("wakeup_words") # 是否开启唤醒词回复 enable_greeting = conn.config.get("enable_greeting", True) @@ -55,10 +62,16 @@ async def handleTextMessage(conn, message): await send_stt_message(conn, original_text) await send_tts_message(conn, "stop", None) conn.client_is_speaking = False + elif is_wakeup_words: + conn.just_woken_up = True + # 上报纯文字数据(复用ASR上报功能,但不提供音频数据) + enqueue_asr_report(conn, "嘿,你好呀", []) + await startToChat(conn, "嘿,你好呀") else: - # 检测到唤醒词,开始等待后续进行声纹识别 - conn.wakeup_mode = True - conn.logger.bind(tag=TAG).info(f"检测到唤醒词~") + # 上报纯文字数据(复用ASR上报功能,但不提供音频数据) + enqueue_asr_report(conn, original_text, []) + # 否则需要LLM对文字内容进行答复 + await startToChat(conn, original_text) elif msg_json["type"] == "iot": conn.logger.bind(tag=TAG).info(f"收到iot消息:{message}") if "descriptors" in msg_json: diff --git a/main/xiaozhi-server/core/providers/asr/base.py b/main/xiaozhi-server/core/providers/asr/base.py index 7885261d..972818d4 100644 --- a/main/xiaozhi-server/core/providers/asr/base.py +++ b/main/xiaozhi-server/core/providers/asr/base.py @@ -1,18 +1,18 @@ import os -import io import wave import uuid -import json -import time import queue import asyncio import traceback import threading import opuslib_next +import json +import io +import time import concurrent.futures from abc import ABC, abstractmethod from config.logger import setup_logging -from typing import Optional, Tuple, List +from typing import Optional, Tuple, List, Dict, Any from core.handle.receiveAudioHandle import startToChat from core.handle.reportHandle import enqueue_asr_report from core.utils.util import remove_punctuation_and_length @@ -53,10 +53,6 @@ class ASRProviderBase(ABC): # 接收音频 async def receive_audio(self, conn, audio, audio_have_voice): - # 检查是否在唤醒处理锁定期内 - if getattr(conn, 'wakeup_processing_lock', 0) > time.monotonic(): - return - if conn.client_listen_mode == "auto" or conn.client_listen_mode == "realtime": have_voice = audio_have_voice else: @@ -66,14 +62,6 @@ class ASRProviderBase(ABC): if not have_voice and not conn.client_have_voice: conn.asr_audio = conn.asr_audio[-10:] return - - # 检查是否处于唤醒模式 - if getattr(conn, 'wakeup_mode', False) and len(conn.asr_audio) >= 10: - asr_audio_task = conn.asr_audio.copy() - conn.reset_vad_states() - conn.asr_audio.clear() - - await self.handle_voice_stop(conn, asr_audio_task) if conn.client_voice_stop: asr_audio_task = conn.asr_audio.copy() @@ -99,13 +87,32 @@ class ASRProviderBase(ABC): # 预先准备WAV数据 wav_data = None + # 使用连接的声纹识别提供者 if conn.voiceprint_provider and combined_pcm_data: wav_data = self._pcm_to_wav(combined_pcm_data) - # 检查是否处于唤醒模式 - wakeup_mode = getattr(conn, 'wakeup_mode', False) - # 声纹识别任务(公共逻辑) + # 定义ASR任务 + def run_asr(): + start_time = time.monotonic() + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + result = loop.run_until_complete( + self.speech_to_text(asr_audio_task, conn.session_id, conn.audio_format) + ) + end_time = time.monotonic() + logger.bind(tag=TAG).info(f"ASR耗时: {end_time - start_time:.3f}s") + return result + finally: + loop.close() + except Exception as e: + end_time = time.monotonic() + logger.bind(tag=TAG).error(f"ASR失败: {e}") + return ("", None) + + # 定义声纹识别任务 def run_voiceprint(): if not wav_data: return None @@ -124,83 +131,50 @@ class ASRProviderBase(ABC): logger.bind(tag=TAG).error(f"声纹识别失败: {e}") return None - if wakeup_mode and conn.voiceprint_provider and wav_data: - conn.wakeup_mode = False - # 设置处理锁,防止后续音频片段重复处理 - conn.wakeup_processing_lock = time.monotonic() + 3 # 3秒锁定期 + # 使用线程池执行器并行运行 + parallel_start_time = time.monotonic() + + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as thread_executor: + asr_future = thread_executor.submit(run_asr) - # 唤醒模式:只执行声纹识别 - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as thread_executor: + if conn.voiceprint_provider and wav_data: voiceprint_future = thread_executor.submit(run_voiceprint) + + # 等待两个线程都完成 + asr_result = asr_future.result(timeout=15) voiceprint_result = voiceprint_future.result(timeout=15) - - speaker_name = voiceprint_result + + results = {"asr": asr_result, "voiceprint": voiceprint_result} + else: + asr_result = asr_future.result(timeout=15) + results = {"asr": asr_result, "voiceprint": None} + + + # 处理结果 + raw_text, file_path = results.get("asr", ("", None)) + speaker_name = results.get("voiceprint", None) + + # 记录识别结果 + if raw_text: + logger.bind(tag=TAG).info(f"识别文本: {raw_text}") + if speaker_name: logger.bind(tag=TAG).info(f"识别说话人: {speaker_name}") - - fixed_text = "嘿,你好啊" - enhanced_text = self._build_enhanced_text(fixed_text, speaker_name) + + # 性能监控 + total_time = time.monotonic() - total_start_time + logger.bind(tag=TAG).info(f"总处理耗时: {total_time:.3f}s") + + # 检查文本长度 + text_len, _ = remove_punctuation_and_length(raw_text) + self.stop_ws_connection() + + if text_len > 0: + # 构建包含说话人信息的JSON字符串 + enhanced_text = self._build_enhanced_text(raw_text, speaker_name) - # 性能监控 - total_time = time.monotonic() - total_start_time - logger.bind(tag=TAG).info(f"唤醒模式总处理耗时: {total_time:.3f}s") - + # 使用自定义模块进行上报 await startToChat(conn, enhanced_text) enqueue_asr_report(conn, enhanced_text, asr_audio_task) - else: - # 正常模式:执行声纹识别和ASR - def run_asr(): - start_time = time.monotonic() - try: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - result = loop.run_until_complete( - self.speech_to_text(asr_audio_task, conn.session_id, conn.audio_format) - ) - end_time = time.monotonic() - logger.bind(tag=TAG).info(f"ASR耗时: {end_time - start_time:.3f}s") - return result - finally: - loop.close() - except Exception as e: - end_time = time.monotonic() - logger.bind(tag=TAG).error(f"ASR失败: {e}") - return ("", None) - - # 使用线程池执行器并行运行 - with concurrent.futures.ThreadPoolExecutor(max_workers=2) as thread_executor: - asr_future = thread_executor.submit(run_asr) - - if conn.voiceprint_provider and wav_data: - voiceprint_future = thread_executor.submit(run_voiceprint) - asr_result = asr_future.result(timeout=15) - voiceprint_result = voiceprint_future.result(timeout=15) - results = {"asr": asr_result, "voiceprint": voiceprint_result} - else: - asr_result = asr_future.result(timeout=15) - results = {"asr": asr_result, "voiceprint": None} - - # 处理结果 - raw_text, _ = results.get("asr", ("", None)) - speaker_name = results.get("voiceprint", None) - - if raw_text: - logger.bind(tag=TAG).info(f"识别文本: {raw_text}") - if speaker_name: - logger.bind(tag=TAG).info(f"识别说话人: {speaker_name}") - - # 性能监控 - total_time = time.monotonic() - total_start_time - logger.bind(tag=TAG).info(f"总处理耗时: {total_time:.3f}s") - - # 检查文本长度 - text_len, _ = remove_punctuation_and_length(raw_text) - self.stop_ws_connection() - - if text_len > 0: - enhanced_text = self._build_enhanced_text(raw_text, speaker_name) - await startToChat(conn, enhanced_text) - enqueue_asr_report(conn, enhanced_text, asr_audio_task) except Exception as e: logger.bind(tag=TAG).error(f"处理语音停止失败: {e}") diff --git a/main/xiaozhi-server/core/providers/tts/aliyun_stream.py b/main/xiaozhi-server/core/providers/tts/aliyun_stream.py index 734681e2..0bfb367b 100644 --- a/main/xiaozhi-server/core/providers/tts/aliyun_stream.py +++ b/main/xiaozhi-server/core/providers/tts/aliyun_stream.py @@ -268,7 +268,11 @@ class TTSProvider(TTSProviderBase): ) if message.content_file and os.path.exists(message.content_file): # 先处理文件音频数据 - self._process_audio_file_stream(message.content_file, callback=lambda audio_data: self.handle_audio_file(audio_data, message.content_detail)) + file_audio = self._process_audio_file(message.content_file) + self.before_stop_play_files.append( + (file_audio, message.content_detail) + ) + if message.sentence_type == SentenceType.LAST: try: logger.bind(tag=TAG).info("开始结束TTS会话...") @@ -418,6 +422,9 @@ class TTSProvider(TTSProviderBase): async def _start_monitor_tts_response(self): """监听TTS响应""" + opus_datas_cache = [] + is_first_sentence = True + first_sentence_segment_count = 0 # 添加计数器 try: session_finished = False # 标记会话是否正常结束 while not self.conn.stop_event.is_set(): @@ -438,16 +445,28 @@ class TTSProvider(TTSProviderBase): self.tts_audio_queue.put( (SentenceType.FIRST, [], None) ) + elif event_name == "SentenceBegin": + opus_datas_cache = [] elif event_name == "SentenceEnd": - # 发送缓存的数据 - if self.conn.tts_MessageText: - logger.bind(tag=TAG).info( - f"句子语音生成成功: {self.conn.tts_MessageText}" - ) - self.tts_audio_queue.put( - (SentenceType.FIRST, [], self.conn.tts_MessageText) - ) - self.conn.tts_MessageText = None + if ( + not is_first_sentence + or first_sentence_segment_count > 10 + ): + # 发送缓存的数据 + if self.conn.tts_MessageText: + logger.bind(tag=TAG).info( + f"句子语音生成成功: {self.conn.tts_MessageText}" + ) + self.tts_audio_queue.put( + (SentenceType.MIDDLE, opus_datas_cache, self.conn.tts_MessageText) + ) + self.conn.tts_MessageText = None + else: + self.tts_audio_queue.put( + (SentenceType.MIDDLE, opus_datas_cache, None) + ) + # 第一句话结束后,将标志设置为False + is_first_sentence = False elif event_name == "SynthesisCompleted": logger.bind(tag=TAG).debug(f"会话结束~~") self._process_before_stop_play_files() @@ -458,7 +477,22 @@ class TTSProvider(TTSProviderBase): # 二进制消息(音频数据) elif isinstance(msg, (bytes, bytearray)): logger.bind(tag=TAG).debug(f"推送数据到队列里面~~") - self.opus_encoder.encode_pcm_to_opus_stream(msg, False, self.handle_opus) + opus_datas = self.opus_encoder.encode_pcm_to_opus(msg, False) + logger.bind(tag=TAG).debug( + f"推送数据到队列里面帧数~~{len(opus_datas)}" + ) + if is_first_sentence: + first_sentence_segment_count += 1 + if first_sentence_segment_count <= 6: + self.tts_audio_queue.put( + (SentenceType.MIDDLE, opus_datas, None) + ) + else: + opus_datas_cache.extend(opus_datas) + else: + # 后续句子缓存 + opus_datas_cache.extend(opus_datas) + except websockets.ConnectionClosed: logger.bind(tag=TAG).warning("WebSocket连接已关闭") break @@ -478,3 +512,142 @@ class TTSProvider(TTSProviderBase): finally: self._monitor_task = None + def to_tts(self, text: str) -> list: + """非流式TTS处理,用于测试及保存音频文件的场景""" + try: + # 创建新的事件循环 + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + # 生成会话ID + session_id = uuid.uuid4().hex + # 存储音频数据 + audio_data = [] + + async def _generate_audio(): + # 刷新Token(如果需要) + if self._is_token_expired(): + self._refresh_token() + + # 建立WebSocket连接 + ws = await websockets.connect( + self.ws_url, + additional_headers={"X-NLS-Token": self.token}, + ping_interval=30, + ping_timeout=10, + close_timeout=10, + ) + try: + # 发送StartSynthesis请求 + start_message_id = str(uuid.uuid4().hex) + start_request = { + "header": { + "message_id": start_message_id, + "task_id": session_id, + "namespace": "FlowingSpeechSynthesizer", + "name": "StartSynthesis", + "appkey": self.appkey, + }, + "payload": { + "voice": self.voice, + "format": self.format, + "sample_rate": self.sample_rate, + "volume": self.volume, + "speech_rate": self.speech_rate, + "pitch_rate": self.pitch_rate, + "enable_subtitle": True, + }, + } + await ws.send(json.dumps(start_request)) + + # 等待SynthesisStarted响应 + synthesis_started = False + while not synthesis_started: + msg = await ws.recv() + if isinstance(msg, str): + data = json.loads(msg) + header = data.get("header", {}) + if header.get("name") == "SynthesisStarted": + synthesis_started = True + logger.bind(tag=TAG).debug("TTS合成已启动") + elif header.get("name") == "TaskFailed": + error_info = data.get("payload", {}).get( + "error_info", {} + ) + error_code = error_info.get("error_code") + error_message = error_info.get( + "error_message", "未知错误" + ) + raise Exception( + f"启动合成失败: {error_code} - {error_message}" + ) + + # 发送文本合成请求 + filtered_text = MarkdownCleaner.clean_markdown(text) + run_message_id = str(uuid.uuid4().hex) + run_request = { + "header": { + "message_id": run_message_id, + "task_id": session_id, + "namespace": "FlowingSpeechSynthesizer", + "name": "RunSynthesis", + "appkey": self.appkey, + }, + "payload": {"text": filtered_text}, + } + await ws.send(json.dumps(run_request)) + + # 发送停止合成请求 + stop_message_id = str(uuid.uuid4().hex) + stop_request = { + "header": { + "message_id": stop_message_id, + "task_id": session_id, + "namespace": "FlowingSpeechSynthesizer", + "name": "StopSynthesis", + "appkey": self.appkey, + } + } + await ws.send(json.dumps(stop_request)) + + # 接收音频数据 + synthesis_completed = False + while not synthesis_completed: + msg = await ws.recv() + if isinstance(msg, (bytes, bytearray)): + # 编码为Opus并收集 + opus_frames = self.opus_encoder.encode_pcm_to_opus( + msg, False + ) + audio_data.extend(opus_frames) + elif isinstance(msg, str): + data = json.loads(msg) + header = data.get("header", {}) + event_name = header.get("name") + if event_name == "SynthesisCompleted": + synthesis_completed = True + logger.bind(tag=TAG).debug("TTS合成完成") + elif event_name == "TaskFailed": + error_info = data.get("payload", {}).get( + "error_info", {} + ) + error_code = error_info.get("error_code") + error_message = error_info.get( + "error_message", "未知错误" + ) + raise Exception( + f"合成失败: {error_code} - {error_message}" + ) + finally: + try: + await ws.close() + except: + pass + + loop.run_until_complete(_generate_audio()) + loop.close() + + return audio_data + except Exception as e: + logger.bind(tag=TAG).error(f"生成音频数据失败: {str(e)}") + return [] diff --git a/main/xiaozhi-server/core/providers/tts/base.py b/main/xiaozhi-server/core/providers/tts/base.py index a65fe194..40570326 100644 --- a/main/xiaozhi-server/core/providers/tts/base.py +++ b/main/xiaozhi-server/core/providers/tts/base.py @@ -4,14 +4,12 @@ import queue import uuid import asyncio import threading -from typing import Callable, Any from core.utils import p3 -import time from datetime import datetime from core.utils import textUtils from abc import ABC, abstractmethod from config.logger import setup_logging -from core.utils.util import audio_bytes_to_data_stream, audio_to_data_stream +from core.utils.util import audio_to_data, audio_bytes_to_data from core.utils.tts import MarkdownCleaner from core.utils.output_counter import add_device_output from core.handle.reportHandle import enqueue_tts_report @@ -33,6 +31,7 @@ class TTSProviderBase(ABC): def __init__(self, config, delete_audio_file): self.interface_type = InterfaceType.NON_STREAM self.conn = None + self.tts_timeout = 10 self.delete_audio_file = delete_audio_file self.audio_file_type = "wav" self.output_file = config.get("output_dir", "tmp/") @@ -51,9 +50,11 @@ class TTSProviderBase(ABC): ";", ";", ":", + "~", ) self.first_sentence_punctuations = ( ",", + "~", "~", "、", ",", @@ -76,14 +77,7 @@ class TTSProviderBase(ABC): f"tts-{datetime.now().date()}@{uuid.uuid4().hex}{extension}", ) - def handle_opus(self, opus_data: bytes): - logger.bind(tag=TAG).debug(f"推送数据到队列里面帧数~~ {len(opus_data)}") - self.tts_audio_queue.put((SentenceType.MIDDLE, opus_data, None)) - - def handle_audio_file(self, file_audio: bytes, text): - self.before_stop_play_files.append((file_audio, text)) - - def to_tts_stream(self, text, opus_handler: Callable[[bytes], None] = None) -> None: + def to_tts(self, text): text = MarkdownCleaner.clean_markdown(text) max_repeat_time = 5 if self.delete_audio_file: @@ -92,14 +86,10 @@ class TTSProviderBase(ABC): try: audio_bytes = asyncio.run(self.text_to_speak(text, None)) if audio_bytes: - self.tts_audio_queue.put((SentenceType.FIRST, None, text)) - audio_bytes_to_data_stream( - audio_bytes, - file_type=self.audio_file_type, - is_opus=True, - callback=opus_handler, + audio_datas, _ = audio_bytes_to_data( + audio_bytes, file_type=self.audio_file_type, is_opus=True ) - break + return audio_datas else: max_repeat_time -= 1 except Exception as e: @@ -139,8 +129,8 @@ class TTSProviderBase(ABC): logger.bind(tag=TAG).error( f"语音生成失败: {text},请检查网络或服务是否正常" ) - self.tts_audio_queue.put((SentenceType.FIRST, None, text)) - self._process_audio_file_stream(tmp_file, callback=opus_handler) + + return tmp_file except Exception as e: logger.bind(tag=TAG).error(f"Failed to generate TTS file: {e}") return None @@ -149,17 +139,13 @@ class TTSProviderBase(ABC): async def text_to_speak(self, text, output_file): pass - def audio_to_pcm_data_stream( - self, audio_file_path, callback: Callable[[Any], Any] = None - ): + def audio_to_pcm_data(self, audio_file_path): """音频文件转换为PCM编码""" - return audio_to_data_stream(audio_file_path, is_opus=False, callback=callback) + return audio_to_data(audio_file_path, is_opus=False) - def audio_to_opus_data_stream( - self, audio_file_path, callback: Callable[[Any], Any] = None - ): + def audio_to_opus_data(self, audio_file_path): """音频文件转换为Opus编码""" - return audio_to_data_stream(audio_file_path, is_opus=True, callback=callback) + return audio_to_data(audio_file_path, is_opus=True) def tts_one_sentence( self, @@ -191,6 +177,7 @@ class TTSProviderBase(ABC): async def open_audio_channels(self, conn): self.conn = conn + self.tts_timeout = conn.config.get("tts_timeout", 10) # tts 消化线程 self.tts_priority_thread = threading.Thread( target=self.tts_text_priority_thread, daemon=True @@ -225,16 +212,30 @@ class TTSProviderBase(ABC): self.tts_text_buff.append(message.content_detail) segment_text = self._get_segment_text() if segment_text: - self.to_tts_stream(segment_text, opus_handler=self.handle_opus) + if self.delete_audio_file: + audio_datas = self.to_tts(segment_text) + if audio_datas: + self.tts_audio_queue.put( + (message.sentence_type, audio_datas, segment_text) + ) + else: + tts_file = self.to_tts(segment_text) + if tts_file: + audio_datas = self._process_audio_file(tts_file) + self.tts_audio_queue.put( + (message.sentence_type, audio_datas, segment_text) + ) elif ContentType.FILE == message.content_type: - self._process_remaining_text_stream(opus_handler=self.handle_opus) + self._process_remaining_text() tts_file = message.content_file if tts_file and os.path.exists(tts_file): - self._process_audio_file_stream( - tts_file, callback=self.handle_opus + audio_datas = self._process_audio_file(tts_file) + self.tts_audio_queue.put( + (message.sentence_type, audio_datas, message.content_detail) ) + if message.sentence_type == SentenceType.LAST: - self._process_remaining_text_stream(opus_handler=self.handle_opus) + self._process_remaining_text() self.tts_audio_queue.put( (message.sentence_type, [], message.content_detail) ) @@ -248,59 +249,29 @@ class TTSProviderBase(ABC): continue def _audio_play_priority_thread(self): - # 需要上报的文本和音频列表 - enqueue_text = None - enqueue_audio = None while not self.conn.stop_event.is_set(): text = None try: try: sentence_type, audio_datas, text = self.tts_audio_queue.get( - timeout=0.1 + timeout=1 ) except queue.Empty: if self.conn.stop_event.is_set(): break continue - - if self.conn.client_abort: - logger.bind(tag=TAG).debug("收到打断信号,跳过当前音频数据") - enqueue_text, enqueue_audio = None, [] - continue - - # 收到下一个文本开始或会话结束时进行上报 - if sentence_type is not SentenceType.MIDDLE: - # 重置音频流控状态(新句子开始或者结束) - if hasattr(self.conn, 'audio_flow_control'): - self.conn.audio_flow_control = { - 'last_send_time': 0, - 'packet_count': 0, - 'start_time': time.perf_counter() - } - - # 上报TTS数据 - if enqueue_text is not None and enqueue_audio is not None: - enqueue_tts_report(self.conn, enqueue_text, enqueue_audio) - enqueue_audio = [] - enqueue_text = text - - # 收集上报音频数据 - if isinstance(audio_datas, bytes) and enqueue_audio is not None: - enqueue_audio.append(audio_datas) - - # 发送音频 future = asyncio.run_coroutine_threadsafe( sendAudioMessage(self.conn, sentence_type, audio_datas, text), self.conn.loop, ) future.result() - - # 记录输出和报告 if self.conn.max_output_size > 0 and text: add_device_output(self.conn.headers.get("device-id"), len(text)) - + enqueue_tts_report(self.conn, text, audio_datas) except Exception as e: - logger.bind(tag=TAG).error(f"audio_play_priority_thread: {text} {e}") + logger.bind(tag=TAG).error( + f"audio_play_priority priority_thread: {text} {e}" + ) async def start_session(self, session_id): pass @@ -352,21 +323,22 @@ class TTSProviderBase(ABC): else: return None - def _process_audio_file_stream( - self, tts_file, callback: Callable[[Any], Any] - ) -> None: + def _process_audio_file(self, tts_file): """处理音频文件并转换为指定格式 Args: tts_file: 音频文件路径 - callback: 文件处理函数 + content_detail: 内容详情 + + Returns: + tuple: (sentence_type, audio_datas, content_detail) """ if tts_file.endswith(".p3"): - p3.decode_opus_from_file_stream(tts_file, callback=callback) + audio_datas, _ = p3.decode_opus_from_file(tts_file) elif self.conn.audio_format == "pcm": - self.audio_to_pcm_data_stream(tts_file, callback=callback) + audio_datas, _ = self.audio_to_pcm_data(tts_file) else: - self.audio_to_opus_data_stream(tts_file, callback=callback) + audio_datas, _ = self.audio_to_opus_data(tts_file) if ( self.delete_audio_file @@ -375,6 +347,7 @@ class TTSProviderBase(ABC): and tts_file.startswith(self.output_file) ): os.remove(tts_file) + return audio_datas def _process_before_stop_play_files(self): for audio_datas, text in self.before_stop_play_files: @@ -382,9 +355,7 @@ class TTSProviderBase(ABC): self.before_stop_play_files.clear() self.tts_audio_queue.put((SentenceType.LAST, [], None)) - def _process_remaining_text_stream( - self, opus_handler: Callable[[bytes], None] = None - ): + def _process_remaining_text(self): """处理剩余的文本并生成语音 Returns: @@ -395,7 +366,18 @@ class TTSProviderBase(ABC): if remaining_text: segment_text = textUtils.get_string_no_punctuation_or_emoji(remaining_text) if segment_text: - self.to_tts_stream(segment_text, opus_handler=opus_handler) + if self.delete_audio_file: + audio_datas = self.to_tts(segment_text) + if audio_datas: + self.tts_audio_queue.put( + (SentenceType.MIDDLE, audio_datas, segment_text) + ) + else: + tts_file = self.to_tts(segment_text) + audio_datas = self._process_audio_file(tts_file) + self.tts_audio_queue.put( + (SentenceType.MIDDLE, audio_datas, segment_text) + ) self.processed_chars += len(full_text) return True return False diff --git a/main/xiaozhi-server/core/providers/tts/huoshan_double_stream.py b/main/xiaozhi-server/core/providers/tts/huoshan_double_stream.py index 463c16fc..33e2750d 100644 --- a/main/xiaozhi-server/core/providers/tts/huoshan_double_stream.py +++ b/main/xiaozhi-server/core/providers/tts/huoshan_double_stream.py @@ -4,7 +4,6 @@ import json import queue import asyncio import traceback -from typing import Callable, Any import websockets from core.utils.tts import MarkdownCleaner from config.logger import setup_logging @@ -267,7 +266,11 @@ class TTSProvider(TTSProviderBase): ) if message.content_file and os.path.exists(message.content_file): # 先处理文件音频数据 - self._process_audio_file_stream(message.content_file, callback=lambda audio_data: self.handle_audio_file(audio_data, message.content_detail)) + file_audio = self._process_audio_file(message.content_file) + self.before_stop_play_files.append( + (file_audio, message.content_detail) + ) + if message.sentence_type == SentenceType.LAST: try: logger.bind(tag=TAG).info("开始结束TTS会话...") @@ -425,6 +428,9 @@ class TTSProvider(TTSProviderBase): async def _start_monitor_tts_response(self): """监听TTS响应""" + opus_datas_cache = [] + is_first_sentence = True + first_sentence_segment_count = 0 # 添加计数器 try: session_finished = False # 标记会话是否正常结束 while not self.conn.stop_event.is_set(): @@ -445,14 +451,37 @@ class TTSProvider(TTSProviderBase): self.tts_audio_queue.put( (SentenceType.FIRST, [], self.tts_text) ) + opus_datas_cache = [] + first_sentence_segment_count = 0 # 重置计数器 elif ( res.optional.event == EVENT_TTSResponse and res.header.message_type == AUDIO_ONLY_RESPONSE ): logger.bind(tag=TAG).debug(f"推送数据到队列里面~~") - self.wav_to_opus_data_audio_raw_stream(res.payload, callback=self.handle_opus) + opus_datas = self.wav_to_opus_data_audio_raw(res.payload) + logger.bind(tag=TAG).debug( + f"推送数据到队列里面帧数~~{len(opus_datas)}" + ) + if is_first_sentence: + first_sentence_segment_count += 1 + if first_sentence_segment_count <= 6: + self.tts_audio_queue.put( + (SentenceType.MIDDLE, opus_datas, None) + ) + else: + opus_datas_cache.extend(opus_datas) + else: + # 后续句子缓存 + opus_datas_cache.extend(opus_datas) elif res.optional.event == EVENT_TTSSentenceEnd: logger.bind(tag=TAG).info(f"句子语音生成成功:{self.tts_text}") + if not is_first_sentence or first_sentence_segment_count > 10: + # 发送缓存的数据 + self.tts_audio_queue.put( + (SentenceType.MIDDLE, opus_datas_cache, None) + ) + # 第一句话结束后,将标志设置为False + is_first_sentence = False elif res.optional.event == EVENT_SessionFinished: logger.bind(tag=TAG).debug(f"会话结束~~") self._process_before_stop_play_files() @@ -626,5 +655,110 @@ class TTSProvider(TTSProviderBase): ) ) - def wav_to_opus_data_audio_raw_stream(self, raw_data_var, is_end=False, callback: Callable[[Any], Any]=None): - return self.opus_encoder.encode_pcm_to_opus_stream(raw_data_var, is_end, callback=callback) + def wav_to_opus_data_audio_raw(self, raw_data_var, is_end=False): + opus_datas = self.opus_encoder.encode_pcm_to_opus(raw_data_var, is_end) + return opus_datas + + def to_tts(self, text: str) -> list: + """非流式生成音频数据,用于生成音频及测试场景 + + Args: + text: 要转换的文本 + + Returns: + list: 音频数据列表 + """ + try: + # 创建事件循环 + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + # 生成会话ID + session_id = uuid.uuid4().__str__().replace("-", "") + + # 存储音频数据 + audio_data = [] + + async def _generate_audio(): + # 创建新的WebSocket连接 + ws_header = { + "X-Api-App-Key": self.appId, + "X-Api-Access-Key": self.access_token, + "X-Api-Resource-Id": self.resource_id, + "X-Api-Connect-Id": uuid.uuid4(), + } + ws = await websockets.connect( + self.ws_url, additional_headers=ws_header, max_size=1000000000 + ) + + try: + # 启动会话 + header = Header( + message_type=FULL_CLIENT_REQUEST, + message_type_specific_flags=MsgTypeFlagWithEvent, + serial_method=JSON, + ).as_bytes() + optional = Optional( + event=EVENT_StartSession, sessionId=session_id + ).as_bytes() + payload = self.get_payload_bytes( + event=EVENT_StartSession, speaker=self.voice + ) + await self.send_event(ws, header, optional, payload) + + # 发送文本 + header = Header( + message_type=FULL_CLIENT_REQUEST, + message_type_specific_flags=MsgTypeFlagWithEvent, + serial_method=JSON, + ).as_bytes() + optional = Optional( + event=EVENT_TaskRequest, sessionId=session_id + ).as_bytes() + payload = self.get_payload_bytes( + event=EVENT_TaskRequest, text=text, speaker=self.voice + ) + await self.send_event(ws, header, optional, payload) + + # 发送结束会话请求 + header = Header( + message_type=FULL_CLIENT_REQUEST, + message_type_specific_flags=MsgTypeFlagWithEvent, + serial_method=JSON, + ).as_bytes() + optional = Optional( + event=EVENT_FinishSession, sessionId=session_id + ).as_bytes() + payload = str.encode("{}") + await self.send_event(ws, header, optional, payload) + + # 接收音频数据 + while True: + msg = await ws.recv() + res = self.parser_response(msg) + + if ( + res.optional.event == EVENT_TTSResponse + and res.header.message_type == AUDIO_ONLY_RESPONSE + ): + opus_datas = self.wav_to_opus_data_audio_raw(res.payload) + audio_data.extend(opus_datas) + elif res.optional.event == EVENT_SessionFinished: + break + + finally: + # 清理资源 + try: + await ws.close() + except: + pass + + # 运行异步任务 + loop.run_until_complete(_generate_audio()) + loop.close() + + return audio_data + + except Exception as e: + logger.bind(tag=TAG).error(f"生成音频数据失败: {str(e)}") + return [] diff --git a/main/xiaozhi-server/core/providers/tts/index_stream.py b/main/xiaozhi-server/core/providers/tts/index_stream.py index 6f6b829c..5ea9eb36 100644 --- a/main/xiaozhi-server/core/providers/tts/index_stream.py +++ b/main/xiaozhi-server/core/providers/tts/index_stream.py @@ -3,6 +3,8 @@ import queue import asyncio import traceback import aiohttp +import requests +import time from config.logger import setup_logging from core.utils.tts import MarkdownCleaner from core.providers.tts.base import TTSProviderBase @@ -25,13 +27,15 @@ class TTSProvider(TTSProviderBase): self.api_url = config.get("api_url", "http://8.138.114.124:11996/tts") self.audio_format = "pcm" self.before_stop_play_files = [] + self.segment_count = 0 # 创建Opus编码器 需注意接口返回的采样率为24000 self.opus_encoder = opus_encoder_utils.OpusEncoderUtils( sample_rate=24000, channels=1, frame_size_ms=60 ) - # PCM缓冲区 + # 文本缓冲区和PCM缓冲区 + self.text_buffer = "" self.pcm_buffer = bytearray() def tts_text_priority_thread(self): @@ -44,6 +48,7 @@ class TTSProvider(TTSProviderBase): self.tts_stop_request = False self.processed_chars = 0 self.tts_text_buff = [] + self.segment_count = 0 self.before_stop_play_files.clear() elif ContentType.TEXT == message.content_type: self.tts_text_buff.append(message.content_detail) @@ -57,11 +62,14 @@ class TTSProvider(TTSProviderBase): ) if message.content_file and os.path.exists(message.content_file): # 先处理文件音频数据 - self._process_audio_file_stream(message.content_file, callback=lambda audio_data: self.handle_audio_file(audio_data, message.content_detail)) + file_audio = self._process_audio_file(message.content_file) + self.before_stop_play_files.append( + (file_audio, message.content_detail) + ) if message.sentence_type == SentenceType.LAST: # 处理剩余的文本 - self._process_remaining_text_stream(True) + self._process_remaining_text(True) except queue.Empty: continue @@ -70,7 +78,7 @@ class TTSProvider(TTSProviderBase): f"处理TTS文本失败: {str(e)}, 类型: {type(e).__name__}, 堆栈: {traceback.format_exc()}" ) - def _process_remaining_text_stream(self, is_last=False): + def _process_remaining_text(self, is_last=False): """处理剩余的文本并生成语音 Returns: bool: 是否成功处理了文本 @@ -135,6 +143,8 @@ class TTSProvider(TTSProviderBase): return self.pcm_buffer.clear() + opus_datas_cache = [] + self.tts_audio_queue.put((SentenceType.FIRST, [], text)) # 处理音频流数据 @@ -148,22 +158,41 @@ class TTSProvider(TTSProviderBase): while len(self.pcm_buffer) >= frame_bytes: frame = bytes(self.pcm_buffer[:frame_bytes]) del self.pcm_buffer[:frame_bytes] - - self.opus_encoder.encode_pcm_to_opus_stream( - frame, - end_of_stream=False, - callback=self.handle_opus + opus = self.opus_encoder.encode_pcm_to_opus( + frame, end_of_stream=False ) + if opus: + if self.segment_count < 10: # 前10个片段直接发送 + self.tts_audio_queue.put( + (SentenceType.MIDDLE, opus, None) + ) + self.segment_count += 1 + else: + opus_datas_cache.extend(opus) # flush 剩余不足一帧的数据 if self.pcm_buffer: - self.opus_encoder.encode_pcm_to_opus_stream( - bytes(self.pcm_buffer), - end_of_stream=True, - callback=self.handle_opus + opus = self.opus_encoder.encode_pcm_to_opus( + bytes(self.pcm_buffer), end_of_stream=True ) + if opus: + if self.segment_count < 10: # 前10个片段直接发送 + # 直接发送 + self.tts_audio_queue.put( + (SentenceType.MIDDLE, opus, None) + ) + self.segment_count += 1 + else: + # 后续片段缓存 + opus_datas_cache.extend(opus) self.pcm_buffer.clear() + # 如果不是前10个片段,发送缓存的数据 + if self.segment_count >= 10 and opus_datas_cache: + self.tts_audio_queue.put( + (SentenceType.MIDDLE, opus_datas_cache, None) + ) + # 如果是最后一段,输出音频获取完毕 if is_last: self._process_before_stop_play_files() @@ -177,3 +206,59 @@ class TTSProvider(TTSProviderBase): await super().close() if hasattr(self, "opus_encoder"): self.opus_encoder.close() + + def to_tts(self, text: str) -> list: + """非流式TTS处理,用于测试及保存音频文件的场景 + + Args: + text: 要转换的文本 + + Returns: + list: 返回opus编码后的音频数据列表 + """ + start_time = time.time() + text = MarkdownCleaner.clean_markdown(text) + + payload = {"text": text, "character": self.character} + + try: + with requests.post(self.api_url, json=payload, timeout=5) as response: + if response.status_code != 200: + logger.bind(tag=TAG).error( + f"TTS请求失败: {response.status_code}, {response.text}" + ) + return [] + + logger.info(f"TTS请求成功: {text}, 耗时: {time.time() - start_time}秒") + + # 使用opus编码器处理PCM数据 + opus_datas = [] + pcm_data = response.content + + # 计算每帧的字节数 + frame_bytes = int( + self.opus_encoder.sample_rate + * self.opus_encoder.channels + * self.opus_encoder.frame_size_ms + / 1000 + * 2 + ) + + # 分帧处理PCM数据 + for i in range(0, len(pcm_data), frame_bytes): + frame = pcm_data[i : i + frame_bytes] + if len(frame) < frame_bytes: + # 最后一帧可能不足,用0填充 + frame = frame + b"\x00" * (frame_bytes - len(frame)) + + opus = self.opus_encoder.encode_pcm_to_opus( + frame, end_of_stream=(i + frame_bytes >= len(pcm_data)) + ) + if opus: + opus_datas.extend(opus) + + return opus_datas + + except Exception as e: + logger.bind(tag=TAG).error(f"TTS请求异常: {e}") + return [] diff --git a/main/xiaozhi-server/core/providers/tts/linkerai.py b/main/xiaozhi-server/core/providers/tts/linkerai.py index 04e83148..d1be586c 100644 --- a/main/xiaozhi-server/core/providers/tts/linkerai.py +++ b/main/xiaozhi-server/core/providers/tts/linkerai.py @@ -1,8 +1,10 @@ import os import queue -import aiohttp import asyncio import traceback +import aiohttp +import requests +import time from config.logger import setup_logging from core.utils.tts import MarkdownCleaner from core.providers.tts.base import TTSProviderBase @@ -22,15 +24,23 @@ class TTSProvider(TTSProviderBase): self.api_url = config.get("api_url") self.audio_format = "pcm" self.before_stop_play_files = [] + self.segment_count = 0 # 添加片段计数器 # 创建Opus编码器 self.opus_encoder = opus_encoder_utils.OpusEncoderUtils( sample_rate=16000, channels=1, frame_size_ms=60 ) + # 添加文本缓冲区 + self.text_buffer = "" + # PCM缓冲区 self.pcm_buffer = bytearray() + ################################################################################### + # linkerai单流式TTS重写父类的方法--开始 + ################################################################################### + def tts_text_priority_thread(self): """流式文本处理线程""" while not self.conn.stop_event.is_set(): @@ -41,6 +51,7 @@ class TTSProvider(TTSProviderBase): self.tts_stop_request = False self.processed_chars = 0 self.tts_text_buff = [] + self.segment_count = 0 self.before_stop_play_files.clear() elif ContentType.TEXT == message.content_type: self.tts_text_buff.append(message.content_detail) @@ -54,10 +65,14 @@ class TTSProvider(TTSProviderBase): ) if message.content_file and os.path.exists(message.content_file): # 先处理文件音频数据 - self._process_audio_file_stream(message.content_file, callback=lambda audio_data: self.handle_audio_file(audio_data, message.content_detail)) + file_audio = self._process_audio_file(message.content_file) + self.before_stop_play_files.append( + (file_audio, message.content_detail) + ) + if message.sentence_type == SentenceType.LAST: # 处理剩余的文本 - self._process_remaining_text_stream(True) + self._process_remaining_text(True) except queue.Empty: continue @@ -66,7 +81,7 @@ class TTSProvider(TTSProviderBase): f"处理TTS文本失败: {str(e)}, 类型: {type(e).__name__}, 堆栈: {traceback.format_exc()}" ) - def _process_remaining_text_stream(self, is_last=False): + def _process_remaining_text(self, is_last=False): """处理剩余的文本并生成语音 Returns: @@ -109,6 +124,10 @@ class TTSProvider(TTSProviderBase): finally: return None + ################################################################################### + # linkerai单流式TTS重写父类的方法--结束 + ################################################################################### + async def text_to_speak(self, text, is_last): """流式处理TTS音频,每句只推送一次音频列表""" await self._tts_request(text, is_last) @@ -157,6 +176,8 @@ class TTSProvider(TTSProviderBase): return self.pcm_buffer.clear() + opus_datas_cache = [] + self.tts_audio_queue.put((SentenceType.FIRST, [], text)) # 兼容 iter_chunked / iter_chunks / iter_any @@ -173,21 +194,41 @@ class TTSProvider(TTSProviderBase): frame = bytes(self.pcm_buffer[:frame_bytes]) del self.pcm_buffer[:frame_bytes] - self.opus_encoder.encode_pcm_to_opus_stream( - frame, - end_of_stream=False, - callback=self.handle_opus + opus = self.opus_encoder.encode_pcm_to_opus( + frame, end_of_stream=False ) + if opus: + if self.segment_count < 10: # 前10个片段直接发送 + self.tts_audio_queue.put( + (SentenceType.MIDDLE, opus, None) + ) + self.segment_count += 1 + else: + opus_datas_cache.extend(opus) # flush 剩余不足一帧的数据 if self.pcm_buffer: - self.opus_encoder.encode_pcm_to_opus_stream( - bytes(self.pcm_buffer), - end_of_stream=True, - callback=self.handle_opus + opus = self.opus_encoder.encode_pcm_to_opus( + bytes(self.pcm_buffer), end_of_stream=True ) + if opus: + if self.segment_count < 10: # 前10个片段直接发送 + # 直接发送 + self.tts_audio_queue.put( + (SentenceType.MIDDLE, opus, None) + ) + self.segment_count += 1 + else: + # 后续片段缓存 + opus_datas_cache.extend(opus) self.pcm_buffer.clear() + # 如果不是前10个片段,发送缓存的数据 + if self.segment_count >= 10 and opus_datas_cache: + self.tts_audio_queue.put( + (SentenceType.MIDDLE, opus_datas_cache, None) + ) + # 如果是最后一段,输出音频获取完毕 if is_last: self._process_before_stop_play_files() @@ -195,3 +236,73 @@ class TTSProvider(TTSProviderBase): except Exception as e: logger.bind(tag=TAG).error(f"TTS请求异常: {e}") self.tts_audio_queue.put((SentenceType.LAST, [], None)) + + def to_tts(self, text: str) -> list: + """非流式TTS处理,用于测试及保存音频文件的场景 + + Args: + text: 要转换的文本 + + Returns: + list: 返回opus编码后的音频数据列表 + """ + start_time = time.time() + text = MarkdownCleaner.clean_markdown(text) + + params = { + "tts_text": text, + "spk_id": self.voice, + "frame_duration": 60, + "stream": False, + "target_sr": 16000, + "audio_format": self.audio_format, + "instruct_text": "请生成一段自然流畅的语音", + } + headers = { + "Authorization": f"Bearer {self.access_token}", + "Content-Type": "application/json", + } + + try: + with requests.get( + self.api_url, params=params, headers=headers, timeout=5 + ) as response: + if response.status_code != 200: + logger.bind(tag=TAG).error( + f"TTS请求失败: {response.status_code}, {response.text}" + ) + return [] + + logger.info(f"TTS请求成功: {text}, 耗时: {time.time() - start_time}秒") + + # 使用opus编码器处理PCM数据 + opus_datas = [] + pcm_data = response.content + + # 计算每帧的字节数 + frame_bytes = int( + self.opus_encoder.sample_rate + * self.opus_encoder.channels + * self.opus_encoder.frame_size_ms + / 1000 + * 2 + ) + + # 分帧处理PCM数据 + for i in range(0, len(pcm_data), frame_bytes): + frame = pcm_data[i : i + frame_bytes] + if len(frame) < frame_bytes: + # 最后一帧可能不足,用0填充 + frame = frame + b"\x00" * (frame_bytes - len(frame)) + + opus = self.opus_encoder.encode_pcm_to_opus( + frame, end_of_stream=(i + frame_bytes >= len(pcm_data)) + ) + if opus: + opus_datas.extend(opus) + + return opus_datas + + except Exception as e: + logger.bind(tag=TAG).error(f"TTS请求异常: {e}") + return [] diff --git a/main/xiaozhi-server/core/providers/tts/paddle_speech.py b/main/xiaozhi-server/core/providers/tts/paddle_speech.py index 4b987b71..7f1c6a23 100644 --- a/main/xiaozhi-server/core/providers/tts/paddle_speech.py +++ b/main/xiaozhi-server/core/providers/tts/paddle_speech.py @@ -8,7 +8,6 @@ import wave import websockets from core.providers.tts.base import TTSProviderBase from config.logger import setup_logging -from datetime import datetime TAG = __name__ logger = setup_logging() @@ -19,12 +18,11 @@ class TTSProvider(TTSProviderBase): super().__init__(config, delete_audio_file) self.url = config.get("url", "ws://192.168.1.10:8092/paddlespeech/tts/streaming") self.protocol = config.get("protocol", "websocket") - if config.get("private_voice"): self.spk_id = int(config.get("private_voice")) else: - self.spk_id = int(config.get("spk_id", "0")) - + self.spk_id = int(config.get("spk_id", "0")) + sample_rate = config.get("sample_rate", 24000) self.sample_rate = float(sample_rate) if sample_rate else 24000 @@ -34,21 +32,7 @@ class TTSProvider(TTSProviderBase): volume = config.get("volume", 1.0) self.volume = float(volume) if volume else 1.0 - self.delete_audio_file = config.get("delete_audio", True) - if not self.delete_audio_file: - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - save_path = config.get("save_path") - if save_path: - if not save_path.endswith('.wav'): - save_path = f"{save_path}_{timestamp}.wav" - else: - other_path = save_path[:-4] - save_path = f"{other_path}_{timestamp}.wav" - self.save_path = save_path - else: - self.save_path = f"./streaming_tts_{timestamp}.wav" - else: - self.save_path = None + self.save_path = config.get("save_path", "./streaming_tts.wav") async def pcm_to_wav(self, pcm_data: bytes, sample_rate: int = 24000, num_channels: int = 1, bits_per_sample: int = 16) -> bytes: @@ -167,12 +151,6 @@ class TTSProvider(TTSProviderBase): # 接收结束响应避免服务抛出异常 await ws.recv() - # 根据配置决定是否保存文件 - if not self.delete_audio_file and self.save_path: - with open(self.save_path, "wb") as f: - f.write(wav_data) - logger.bind(tag=TAG).info(f"音频文件已保存到: {self.save_path}") - # 返回或保存音频数据 if output_file: with open(output_file, "wb") as file_to_save: @@ -181,4 +159,4 @@ class TTSProvider(TTSProviderBase): return wav_data except Exception as e: - raise Exception(f"Error during TTS WebSocket request: {e} while processing text: {text}") \ No newline at end of file + raise Exception(f"Error during TTS WebSocket request: {e} while processing text: {text}") diff --git a/main/xiaozhi-server/core/providers/vad/silero.py b/main/xiaozhi-server/core/providers/vad/silero.py index 95ab8ff3..80e65b55 100644 --- a/main/xiaozhi-server/core/providers/vad/silero.py +++ b/main/xiaozhi-server/core/providers/vad/silero.py @@ -33,7 +33,7 @@ class VADProvider(VADProviderBase): int(min_silence_duration_ms) if min_silence_duration_ms else 1000 ) - # 至少要多少帧才算有语音,增加灵敏度 + # 至少要多少帧才算有语音 self.frame_window_threshold = 1 def is_vad(self, conn, opus_packet): @@ -70,7 +70,9 @@ class VADProvider(VADProviderBase): # 更新滑动窗口 conn.client_voice_window.append(is_voice) - client_have_voice = (conn.client_voice_window.count(True) >= self.frame_window_threshold) + client_have_voice = ( + conn.client_voice_window.count(True) >= self.frame_window_threshold + ) # 如果之前有声音,但本次没有声音,且与上次有声音的时间差已经超过了静默阈值,则认为已经说完一句话 if conn.client_have_voice and not client_have_voice: diff --git a/main/xiaozhi-server/core/utils/opus_encoder_utils.py b/main/xiaozhi-server/core/utils/opus_encoder_utils.py index 8ca406d3..23d26c39 100644 --- a/main/xiaozhi-server/core/utils/opus_encoder_utils.py +++ b/main/xiaozhi-server/core/utils/opus_encoder_utils.py @@ -5,8 +5,9 @@ Opus编码工具类 import logging import traceback + import numpy as np -from typing import Optional, Callable, Any +from typing import List, Optional from opuslib_next import Encoder from opuslib_next import constants @@ -55,14 +56,13 @@ class OpusEncoderUtils: self.encoder.reset_state() self.buffer = np.array([], dtype=np.int16) - def encode_pcm_to_opus_stream(self, pcm_data: bytes, end_of_stream: bool, callback: Callable[[Any], Any]): + def encode_pcm_to_opus(self, pcm_data: bytes, end_of_stream: bool) -> List[bytes]: """ - 将PCM数据编码为Opus格式,以流式方式进行处理 + 将PCM数据编码为Opus格式 Args: pcm_data: PCM字节数据 - end_of_stream: 是否为流的结束, - callback: opus处理方法 + end_of_stream: 是否为流的结束 Returns: Opus数据包列表 @@ -76,6 +76,7 @@ class OpusEncoderUtils: # 将新数据追加到缓冲区 self.buffer = np.append(self.buffer, new_samples) + opus_packets = [] offset = 0 # 处理所有完整帧 @@ -83,7 +84,7 @@ class OpusEncoderUtils: frame = self.buffer[offset : offset + self.total_frame_size] output = self._encode(frame) if output: - callback(output) + opus_packets.append(output) offset += self.total_frame_size # 保留未处理的样本 @@ -97,9 +98,11 @@ class OpusEncoderUtils: output = self._encode(last_frame) if output: - callback(output) + opus_packets.append(output) self.buffer = np.array([], dtype=np.int16) + return opus_packets + def _encode(self, frame: np.ndarray) -> Optional[bytes]: """编码一帧音频数据""" try: diff --git a/main/xiaozhi-server/core/utils/p3.py b/main/xiaozhi-server/core/utils/p3.py index 415e5366..c75b968e 100644 --- a/main/xiaozhi-server/core/utils/p3.py +++ b/main/xiaozhi-server/core/utils/p3.py @@ -1,12 +1,15 @@ -import io import struct -from typing import Callable, Any +def decode_opus_from_file(input_file): + """ + 从p3文件中解码 Opus 数据,并返回一个 Opus 数据包的列表以及总时长。 + """ + opus_datas = [] + total_frames = 0 + sample_rate = 16000 # 文件采样率 + frame_duration_ms = 60 # 帧时长 + frame_size = int(sample_rate * frame_duration_ms / 1000) -def decode_opus_from_file_stream(input_file, callback: Callable[[Any], Any]): - """ - 从p3文件中解码 Opus 数据,由 callback 处理 Opus 数据包。 - """ with open(input_file, 'rb') as f: while True: # 读取头部(4字节):[1字节类型,1字节保留,2字节长度] @@ -22,13 +25,23 @@ def decode_opus_from_file_stream(input_file, callback: Callable[[Any], Any]): if len(opus_data) != data_len: raise ValueError(f"Data length({len(opus_data)}) mismatch({data_len}) in the file.") - callback(opus_data) + opus_datas.append(opus_data) + total_frames += 1 + # 计算总时长 + total_duration = (total_frames * frame_duration_ms) / 1000.0 + return opus_datas, total_duration -def decode_opus_from_bytes_stream(input_bytes, callback: Callable[[Any], Any]): +def decode_opus_from_bytes(input_bytes): """ - 从p3二进制数据中解码 Opus 数据,由 callback 处理 Opus 数据包。 + 从p3二进制数据中解码 Opus 数据,并返回一个 Opus 数据包的列表以及总时长。 """ + import io + opus_datas = [] + total_frames = 0 + sample_rate = 16000 # 文件采样率 + frame_duration_ms = 60 # 帧时长 + frame_size = int(sample_rate * frame_duration_ms / 1000) f = io.BytesIO(input_bytes) while True: @@ -39,4 +52,8 @@ def decode_opus_from_bytes_stream(input_bytes, callback: Callable[[Any], Any]): opus_data = f.read(data_len) if len(opus_data) != data_len: raise ValueError(f"Data length({len(opus_data)}) mismatch({data_len}) in the bytes.") - callback(opus_data) + opus_datas.append(opus_data) + total_frames += 1 + + total_duration = (total_frames * frame_duration_ms) / 1000.0 + return opus_datas, total_duration \ No newline at end of file diff --git a/main/xiaozhi-server/core/utils/util.py b/main/xiaozhi-server/core/utils/util.py index a608049c..ce6f20ea 100644 --- a/main/xiaozhi-server/core/utils/util.py +++ b/main/xiaozhi-server/core/utils/util.py @@ -1,17 +1,16 @@ +import json +import socket +import subprocess import re import os -import json -import copy -import socket -import requests -import subprocess -import numpy as np -import opuslib_next +import wave from io import BytesIO from core.utils import p3 +import numpy as np +import requests +import opuslib_next from pydub import AudioSegment -from typing import Callable, Any -from core.providers.tts.dto.dto import SentenceType +import copy TAG = __name__ emoji_map = { @@ -212,7 +211,7 @@ def extract_json_from_string(input_string): return None -def audio_to_data_stream(audio_file_path, is_opus=True, callback: Callable[[Any], Any]=None) -> None: +def audio_to_data(audio_file_path, is_opus=True): # 获取文件后缀名 file_type = os.path.splitext(audio_file_path)[1] if file_type: @@ -225,29 +224,33 @@ def audio_to_data_stream(audio_file_path, is_opus=True, callback: Callable[[Any] # 转换为单声道/16kHz采样率/16位小端编码(确保与编码器匹配) audio = audio.set_channels(1).set_frame_rate(16000).set_sample_width(2) + # 音频时长(秒) + duration = len(audio) / 1000.0 + # 获取原始PCM数据(16位小端) raw_data = audio.raw_data - pcm_to_data_stream(raw_data, is_opus, callback) + return pcm_to_data(raw_data, is_opus), duration -def audio_bytes_to_data_stream(audio_bytes, file_type, is_opus, callback: Callable[[Any], Any]) -> None: +def audio_bytes_to_data(audio_bytes, file_type, is_opus=True): """ 直接用音频二进制数据转为opus/pcm数据,支持wav、mp3、p3 """ if file_type == "p3": # 直接用p3解码 - return p3.decode_opus_from_bytes_stream(audio_bytes, callback) + return p3.decode_opus_from_bytes(audio_bytes) else: # 其他格式用pydub audio = AudioSegment.from_file( BytesIO(audio_bytes), format=file_type, parameters=["-nostdin"] ) audio = audio.set_channels(1).set_frame_rate(16000).set_sample_width(2) + duration = len(audio) / 1000.0 raw_data = audio.raw_data - pcm_to_data_stream(raw_data, is_opus, callback) + return pcm_to_data(raw_data, is_opus), duration -def pcm_to_data_stream(raw_data, is_opus=True, callback: Callable[[Any], Any] = None): +def pcm_to_data(raw_data, is_opus=True): # 初始化Opus编码器 encoder = opuslib_next.Encoder(16000, 1, opuslib_next.APPLICATION_AUDIO) @@ -255,6 +258,7 @@ def pcm_to_data_stream(raw_data, is_opus=True, callback: Callable[[Any], Any] = frame_duration = 60 # 60ms per frame frame_size = int(16000 * frame_duration / 1000) # 960 samples/frame + datas = [] # 按帧处理所有音频数据(包括最后一帧可能补零) for i in range(0, len(raw_data), frame_size * 2): # 16bit=2bytes/sample # 获取当前帧的二进制数据 @@ -269,28 +273,40 @@ def pcm_to_data_stream(raw_data, is_opus=True, callback: Callable[[Any], Any] = np_frame = np.frombuffer(chunk, dtype=np.int16) # 编码Opus数据 frame_data = encoder.encode(np_frame.tobytes(), frame_size) - callback(frame_data) else: frame_data = chunk if isinstance(chunk, bytes) else bytes(chunk) - callback(frame_data) -def play_audio_response(conn, response): - """音频响应处理""" - conn.tts.tts_audio_queue.put((SentenceType.FIRST, [], response.get("text"))) - play_audio_frames(conn, response.get("file_path")) - conn.tts.tts_audio_queue.put((SentenceType.LAST, [], None)) + datas.append(frame_data) + + return datas -def play_audio_frames(conn, file_path): - """播放音频文件并处理发送帧数据""" - def handle_audio_frame(frame_data): - conn.tts.tts_audio_queue.put((SentenceType.MIDDLE, frame_data, None)) +def opus_datas_to_wav_bytes(opus_datas, sample_rate=16000, channels=1): + """ + 将opus帧列表解码为wav字节流 + """ + decoder = opuslib_next.Decoder(sample_rate, channels) + pcm_datas = [] + + frame_duration = 60 # ms + frame_size = int(sample_rate * frame_duration / 1000) # 960 + + for opus_frame in opus_datas: + # 解码为PCM(返回bytes,2字节/采样点) + pcm = decoder.decode(opus_frame, frame_size) + pcm_datas.append(pcm) + + pcm_bytes = b"".join(pcm_datas) + + # 写入wav字节流 + wav_buffer = BytesIO() + with wave.open(wav_buffer, "wb") as wf: + wf.setnchannels(channels) + wf.setsampwidth(2) # 16bit + wf.setframerate(sample_rate) + wf.writeframes(pcm_bytes) + return wav_buffer.getvalue() - audio_to_data_stream( - file_path, - is_opus=True, - callback=handle_audio_frame - ) def check_vad_update(before_config, new_config): if ( diff --git a/main/xiaozhi-server/core/utils/wakeup_word.py b/main/xiaozhi-server/core/utils/wakeup_word.py new file mode 100644 index 00000000..ab58f290 --- /dev/null +++ b/main/xiaozhi-server/core/utils/wakeup_word.py @@ -0,0 +1,140 @@ +import os +import re +import yaml +import time +import hashlib +import portalocker +from typing import Dict + + +class FileLock: + def __init__(self, file, timeout=5): + self.file = file + self.timeout = timeout + self.start_time = None + + def __enter__(self): + self.start_time = time.time() + while True: + try: + portalocker.lock(self.file, portalocker.LOCK_EX | portalocker.LOCK_NB) + return self.file + except portalocker.LockException: + if time.time() - self.start_time > self.timeout: + raise TimeoutError("获取文件锁超时") + time.sleep(0.1) + + def __exit__(self, exc_type, exc_val, exc_tb): + portalocker.unlock(self.file) + + +class WakeupWordsConfig: + def __init__(self): + self.config_file = "data/.wakeup_words.yaml" + self.assets_dir = "config/assets/wakeup_words" + self._ensure_directories() + self._config_cache = None + self._last_load_time = 0 + self._cache_ttl = 1 # 缓存有效期(秒) + self._lock_timeout = 5 # 文件锁超时时间(秒) + + def _ensure_directories(self): + """确保必要的目录存在""" + os.makedirs(os.path.dirname(self.config_file), exist_ok=True) + os.makedirs(self.assets_dir, exist_ok=True) + + def _load_config(self) -> Dict: + """加载配置文件,使用缓存机制""" + current_time = time.time() + + # 如果缓存有效,直接返回缓存 + if ( + self._config_cache is not None + and current_time - self._last_load_time < self._cache_ttl + ): + return self._config_cache + + try: + with open(self.config_file, "a+") as f: + with FileLock(f, timeout=self._lock_timeout): + f.seek(0) + content = f.read() + config = yaml.safe_load(content) if content else {} + self._config_cache = config + self._last_load_time = current_time + return config + except (TimeoutError, IOError) as e: + print(f"加载配置文件失败: {e}") + return {} + except Exception as e: + print(f"加载配置文件时发生未知错误: {e}") + return {} + + def _save_config(self, config: Dict): + """保存配置到文件,使用文件锁保护""" + try: + with open(self.config_file, "w") as f: + with FileLock(f, timeout=self._lock_timeout): + yaml.dump(config, f, allow_unicode=True) + self._config_cache = config + self._last_load_time = time.time() + except (TimeoutError, IOError) as e: + print(f"保存配置文件失败: {e}") + raise + except Exception as e: + print(f"保存配置文件时发生未知错误: {e}") + raise + + def get_wakeup_response(self, voice: str) -> Dict: + voice = hashlib.md5(voice.encode()).hexdigest() + """获取唤醒词回复配置""" + config = self._load_config() + + if not config or voice not in config: + return None + + # 检查文件大小 + file_path = config[voice]["file_path"] + if not os.path.exists(file_path) or os.stat(file_path).st_size < (15 * 1024): + return None + + return config[voice] + + def update_wakeup_response(self, voice: str, file_path: str, text: str): + """更新唤醒词回复配置""" + try: + # 过滤表情符号 + filtered_text = re.sub(r'[\U0001F600-\U0001F64F\U0001F900-\U0001F9FF]', '', text) + + config = self._load_config() + voice_hash = hashlib.md5(voice.encode()).hexdigest() + config[voice_hash] = { + "voice": voice, + "file_path": file_path, + "time": time.time(), + "text": filtered_text, + } + self._save_config(config) + except Exception as e: + print(f"更新唤醒词回复配置失败: {e}") + raise + + def generate_file_path(self, voice: str) -> str: + """生成音频文件路径,使用voice的哈希值作为文件名""" + try: + # 生成voice的哈希值 + voice_hash = hashlib.md5(voice.encode()).hexdigest() + file_path = os.path.join(self.assets_dir, f"{voice_hash}.wav") + + # 如果文件已存在,先删除 + if os.path.exists(file_path): + try: + os.remove(file_path) + except Exception as e: + print(f"删除已存在的音频文件失败: {e}") + raise + + return file_path + except Exception as e: + print(f"生成音频文件路径失败: {e}") + raise diff --git a/main/xiaozhi-server/plugins_func/functions/play_music.py b/main/xiaozhi-server/plugins_func/functions/play_music.py index dd967d0a..2cbc4018 100644 --- a/main/xiaozhi-server/plugins_func/functions/play_music.py +++ b/main/xiaozhi-server/plugins_func/functions/play_music.py @@ -212,6 +212,7 @@ async def play_local_music(conn, specific_file=None): conn.logger.bind(tag=TAG).error(f"选定的音乐文件不存在: {music_path}") return text = _get_random_play_prompt(selected_music) + await send_stt_message(conn, text) conn.dialogue.put(Message(role="assistant", content=text)) if conn.intent_type == "intent_llm": From 152087443250703dd7d8a594da3357d4c8868200 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Sat, 30 Aug 2025 23:02:50 +0800 Subject: [PATCH 02/13] =?UTF-8?q?update:=E5=8D=87=E7=BA=A7=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/xiaozhi/common/constant/Constant.java | 2 +- main/manager-mobile/src/pages/settings/index.vue | 2 +- main/xiaozhi-server/config/logger.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 bf747600..ba6567a4 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 @@ -237,7 +237,7 @@ public interface Constant { /** * 版本号 */ - public static final String VERSION = "0.7.5"; + public static final String VERSION = "0.7.6"; /** * 无效固件URL diff --git a/main/manager-mobile/src/pages/settings/index.vue b/main/manager-mobile/src/pages/settings/index.vue index 41d16c00..0cecd6bf 100644 --- a/main/manager-mobile/src/pages/settings/index.vue +++ b/main/manager-mobile/src/pages/settings/index.vue @@ -222,7 +222,7 @@ function showAbout() { title: `关于${import.meta.env.VITE_APP_TITLE}`, content: `${import.meta.env.VITE_APP_TITLE}\n\n基于 Vue.js 3 + uni-app 构建的跨平台移动端管理应用,为小智ESP32智能硬件提供设备管理、智能体配置等功能。\n\n© 2025 xiaozhi-esp32-server`, title: `关于小智智控台`, - content: `小智智控台\n\n基于 Vue.js 3 + uni-app 构建的跨平台移动端管理应用,为小智ESP32智能硬件提供设备管理、智能体配置等功能。\n\n© 2025 xiaozhi-esp32-server 0.7.5`, + content: `小智智控台\n\n基于 Vue.js 3 + uni-app 构建的跨平台移动端管理应用,为小智ESP32智能硬件提供设备管理、智能体配置等功能。\n\n© 2025 xiaozhi-esp32-server 0.7.6`, showCancel: false, confirmText: '确定', }) diff --git a/main/xiaozhi-server/config/logger.py b/main/xiaozhi-server/config/logger.py index cd4d9965..5a815f06 100644 --- a/main/xiaozhi-server/config/logger.py +++ b/main/xiaozhi-server/config/logger.py @@ -5,7 +5,7 @@ from config.config_loader import load_config from config.settings import check_config_file from datetime import datetime -SERVER_VERSION = "0.7.5" +SERVER_VERSION = "0.7.6" _logger_initialized = False From ff3c0ab7ca69732168c52514060e0c65f95f8015 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Sat, 30 Aug 2025 23:58:11 +0800 Subject: [PATCH 03/13] =?UTF-8?q?update:=E4=BF=AE=E5=A4=8Dmcp=20input?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F=E6=B1=A1=E6=9F=93=E5=85=B6=E4=BB=96input?= =?UTF-8?q?=E7=9A=84bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/manager-web/src/components/FunctionDialog.vue | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/main/manager-web/src/components/FunctionDialog.vue b/main/manager-web/src/components/FunctionDialog.vue index 76c69c8e..f8adfef7 100644 --- a/main/manager-web/src/components/FunctionDialog.vue +++ b/main/manager-web/src/components/FunctionDialog.vue @@ -698,15 +698,10 @@ export default { font-size: 14px; height: 36px; box-sizing: border-box; - background-color: #f5f5f5; -} -::v-deep .el-input__inner { - background-color: #f5f5f5; - padding-right: 80px; -} - -.url-input { + ::v-deep .el-input__inner { + background-color: #f5f5f5 !important; + } ::v-deep .el-input__suffix { right: 0; From 87f50e52532cc9bc3bd826b44f9ad2c4125122c9 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Sun, 31 Aug 2025 02:56:22 +0800 Subject: [PATCH 04/13] =?UTF-8?q?update=EF=BC=9A=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=9A=84=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index aa5ce96e..862e989a 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,8 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/ | dify 接口调用 | Dify | - | | fastgpt 接口调用 | Fastgpt | - | | coze 接口调用 | Coze | - | +| xinference 接口调用 | Xinference | - | +| homeassistant 接口调用 | HomeAssistant | - | 实际上,任何支持 openai 接口调用的 LLM 均可接入使用。 @@ -302,8 +304,8 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/ | 使用方式 | 支持平台 | 免费平台 | |:---:|:---:|:---:| -| 接口调用 | EdgeTTS、火山引擎豆包TTS、腾讯云、阿里云TTS、CosyVoiceSiliconflow、TTS302AI、CozeCnTTS、GizwitsTTS、ACGNTTS、OpenAITTS、灵犀流式TTS | 灵犀流式TTS、EdgeTTS、CosyVoiceSiliconflow(部分) | -| 本地服务 | FishSpeech、GPT_SOVITS_V2、GPT_SOVITS_V3、MinimaxTTS | FishSpeech、GPT_SOVITS_V2、GPT_SOVITS_V3、MinimaxTTS | +| 接口调用 | EdgeTTS、火山引擎豆包TTS、腾讯云、阿里云TTS、阿里云流式TTS、CosyVoiceSiliconflow、TTS302AI、CozeCnTTS、GizwitsTTS、ACGNTTS、OpenAITTS、灵犀流式TTS、MinimaxTTS、火山双流式TTS | 灵犀流式TTS、EdgeTTS、CosyVoiceSiliconflow(部分) | +| 本地服务 | FishSpeech、GPT_SOVITS_V2、GPT_SOVITS_V3、Index-TTS、PaddleSpeech | Index-TTS、PaddleSpeech、FishSpeech、GPT_SOVITS_V2、GPT_SOVITS_V3 | --- @@ -320,7 +322,7 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/ | 使用方式 | 支持平台 | 免费平台 | |:---:|:---:|:---:| | 本地使用 | FunASR、SherpaASR | FunASR、SherpaASR | -| 接口调用 | DoubaoASR、FunASRServer、TencentASR、AliyunASR | FunASRServer | +| 接口调用 | DoubaoASR、Doubao流式ASR、FunASRServer、TencentASR、AliyunASR、Aliyun流式ASR、百度ASR、OpenAI ASR | FunASRServer | --- @@ -338,6 +340,7 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/ |:------:|:---------------:|:----:|:---------:|:--:| | Memory | mem0ai | 接口调用 | 1000次/月额度 | | | Memory | mem_local_short | 本地总结 | 免费 | | +| Memory | nomem | 无记忆模式 | 免费 | | --- @@ -347,6 +350,7 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/ |:------:|:-------------:|:----:|:-------:|:---------------------:| | Intent | intent_llm | 接口调用 | 根据LLM收费 | 通过大模型识别意图,通用性强 | | Intent | function_call | 接口调用 | 根据LLM收费 | 通过大模型函数调用完成意图,速度快,效果好 | +| Intent | nointent | 无意图模式 | 免费 | 不进行意图识别,直接返回对话结果 | --- From 44dd7fc58ccf234abe67a63a98712cb53f62c38e Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Sun, 31 Aug 2025 07:25:14 +0800 Subject: [PATCH 05/13] =?UTF-8?q?update:=E4=BC=98=E5=8C=96=E7=BC=96?= =?UTF-8?q?=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile-web | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile-web b/Dockerfile-web index 35c25ed2..a347a88e 100644 --- a/Dockerfile-web +++ b/Dockerfile-web @@ -18,12 +18,12 @@ FROM bellsoft/liberica-runtime-container:jre-21-glibc # 安装Nginx和字体库 RUN apk update && \ - apk add --no-cache nginx bash && \ - apk add --no-cache fontconfig ttf-dejavu msttcorefonts-installer && \ + apk add --no-cache nginx bash fontconfig ttf-dejavu && \ + apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/testing/ msttcorefonts-installer || true && \ rm -rf /var/cache/apk/* # 更新字体缓存 -RUN printf 'YES\n' | update-ms-fonts && fc-cache -f -v +RUN (printf 'YES\n' | update-ms-fonts || true) && fc-cache -f -v # 配置Nginx COPY docs/docker/nginx.conf /etc/nginx/nginx.conf From 32e6ecf00d8ac112f1acf5077bd3ede62cb302c5 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Sun, 31 Aug 2025 08:06:41 +0800 Subject: [PATCH 06/13] =?UTF-8?q?update:=E6=B7=BB=E5=8A=A0=E7=BC=96?= =?UTF-8?q?=E8=AF=91=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/docker-image.yml | 13 ++++++++++++- Dockerfile-server | 11 +++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index e54cdd4e..aefa77d0 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -31,6 +31,9 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + with: + driver-opts: | + network=host - name: Login to GitHub Container Registry uses: docker/login-action@v3 @@ -60,6 +63,10 @@ jobs: tags: | ${{ env.IS_VERSION == 'true' && format('ghcr.io/{0}:server_{1},ghcr.io/{0}:server_latest', github.repository, env.VERSION) || format('ghcr.io/{0}:server_latest', github.repository) }} platforms: linux/amd64,linux/arm64 + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + BUILDKIT_PROGRESS=plain # 构建 manager-api 镜像 - name: Build and push manager-web @@ -70,4 +77,8 @@ jobs: push: true tags: | ${{ env.IS_VERSION == 'true' && format('ghcr.io/{0}:web_{1},ghcr.io/{0}:web_latest', github.repository, env.VERSION) || format('ghcr.io/{0}:web_latest', github.repository) }} - platforms: linux/amd64,linux/arm64 \ No newline at end of file + platforms: linux/amd64,linux/arm64 + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + BUILDKIT_PROGRESS=plain \ No newline at end of file diff --git a/Dockerfile-server b/Dockerfile-server index 6e87f7c0..a12fbb11 100644 --- a/Dockerfile-server +++ b/Dockerfile-server @@ -3,10 +3,17 @@ FROM python:3.10-slim AS builder WORKDIR /app +# 配置pip使用国内镜像源(阿里云)并设置超时和重试 +RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && \ + pip config set global.trusted-host mirrors.aliyun.com && \ + pip config set global.timeout 120 && \ + pip config set install.retries 5 + COPY main/xiaozhi-server/requirements.txt . -# 安装Python依赖 -RUN pip install --no-cache-dir -r requirements.txt +# 安装Python依赖,使用并行下载 +RUN pip install --no-cache-dir --upgrade pip setuptools wheel && \ + pip install --no-cache-dir -r requirements.txt --default-timeout=120 --retries 5 # 第二阶段:生产镜像 FROM python:3.10-slim From d31761e78a5724f6acbb4fab0c3a48a3e9eb5ade Mon Sep 17 00:00:00 2001 From: 3030332422 <3030332422@qq.com> Date: Mon, 1 Sep 2025 16:08:51 +0800 Subject: [PATCH 07/13] =?UTF-8?q?fix:=E6=B7=BB=E5=8A=A0user-agent=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=96=B0=E9=97=BB403=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plugins_func/functions/get_news_from_newsnow.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/main/xiaozhi-server/plugins_func/functions/get_news_from_newsnow.py b/main/xiaozhi-server/plugins_func/functions/get_news_from_newsnow.py index 54641106..1d60aefd 100644 --- a/main/xiaozhi-server/plugins_func/functions/get_news_from_newsnow.py +++ b/main/xiaozhi-server/plugins_func/functions/get_news_from_newsnow.py @@ -125,7 +125,8 @@ def fetch_news_from_api(conn, source="thepaper"): ]["get_news_from_newsnow"].get("url"): api_url = conn.config["plugins"]["get_news_from_newsnow"]["url"] + source - response = requests.get(api_url, timeout=10) + headers = {"User-Agent": "Mozilla/5.0"} + response = requests.get(api_url, headers=headers, timeout=10) response.raise_for_status() data = response.json() @@ -144,7 +145,8 @@ def fetch_news_from_api(conn, source="thepaper"): def fetch_news_detail(url): """获取新闻详情页内容并使用MarkItDown清理HTML""" try: - response = requests.get(url, timeout=10) + headers = {"User-Agent": "Mozilla/5.0"} + response = requests.get(url, headers=headers, timeout=10) response.raise_for_status() # 使用MarkItDown清理HTML内容 From 0364d1199b4c447fdf3bea2d4da97ab7922e063a Mon Sep 17 00:00:00 2001 From: 3030332422 <3030332422@qq.com> Date: Tue, 2 Sep 2025 11:23:46 +0800 Subject: [PATCH 08/13] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=E5=A4=A9?= =?UTF-8?q?=E6=B0=94=E6=8F=92=E4=BB=B6=E4=BD=BF=E7=94=A8=E6=8C=87=E5=8D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/Deployment_all.md | 3 +- docs/weather-integration.md | 292 ++++++++++++++++++++++++++++++++++++ 2 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 docs/weather-integration.md diff --git a/docs/Deployment_all.md b/docs/Deployment_all.md index 135b7cf5..96b3d75c 100644 --- a/docs/Deployment_all.md +++ b/docs/Deployment_all.md @@ -476,7 +476,8 @@ ws://你电脑局域网的ip:8000/xiaozhi/v1/ 4、[如何部署MCP接入点](./mcp-endpoint-enable.md)
5、[如何接入MCP接入点](./mcp-endpoint-integration.md)
6、[如何开启声纹识别](./voiceprint-integration.md)
-10、[新闻插件源配置指南](./newsnow_plugin_config.md)
+7、[新闻插件源配置指南](./newsnow_plugin_config.md)
+8、[天气插件使用指南](./weather-integration.md)
## 语音克隆、本地语音部署相关教程 1、[如何部署集成index-tts本地语音](./index-stream-integration.md)
2、[如何部署集成fish-speech本地语音](./fish-speech-integration.md)
diff --git a/docs/weather-integration.md b/docs/weather-integration.md new file mode 100644 index 00000000..021b3f90 --- /dev/null +++ b/docs/weather-integration.md @@ -0,0 +1,292 @@ +# 天气插件使用指南 + +## 概述 + +天气插件 `get_weather` 是小智ESP32语音助手的核心功能之一,支持通过语音查询全国各地的天气信息。插件基于和风天气API,提供实时天气和7天天气预报功能。 + +## 功能特性 + +- **智能位置识别**:根据用户IP自动识别所在城市 +- **多地点查询**:支持全国各城市、地区天气查询 +- **详细天气信息**:包含当前天气、温度、湿度、风力等详细参数 +- **7天预报**:提供未来一周的天气趋势 +- **缓存机制**:优化查询速度,减少API调用 +- **智能解析**:支持省份、城市、地标等多种地点表达方式 + +## 配置方式 + +### 1. 通过Web管理界面配置(推荐) + +1. 登录智控台 +2. 进入"角色配置"页面 +3. 选择要配置的智能体 +4. 点击"编辑功能"按钮 +5. 在右侧参数配置区域找到"天气查询"插件 +6. 配置相关参数(天气插件API密钥、默认查询城市等) + +### 2. 配置文件方式 + +在 `config.yaml` 中配置: + +```yaml +plugins: + get_weather: + api_host: "你的和风天气API主机地址" # 和风天气API主机地址 + api_key: "你的和风天气API密钥" # 必填:API密钥 + default_location: "广州" # 默认查询城市 +``` + +## API Key 申请指南 + +### 1. 注册和风天气账号 + +1. 访问 [和风天气控制台](https://console.qweather.com/) +2. 注册账号并完成邮箱验证 +3. 登录控制台 + +### 2. 创建应用获取API Key + +1. 进入控制台后,点击右侧"项目管理" → "创建项目" +2. 填写项目信息: + - **项目名称**:如"小智语音助手" +3. 点击保存 +4. 创建完成后,在该项目中点击"创建凭据" +5. 填写凭据信息: + - **凭据名称**:如"小智语音助手" + - **身份认证方式**:选择"API Key" +6. 点击保存 +7. 在凭据中复制API Key到配置文件中 + +### 3. 获取API Host + +1. 在控制台中点击"设置" → "API Host" +2. 查看分配给你的专属API Host地址 +3. 将地址复制到配置文件的 `api_host` 字段 + +### 4. 额度说明 + +- **免费额度**:每天1000次免费调用 +- **付费计划**:超出免费额度后按调用次数计费 +- **建议**:个人使用免费额度通常足够 + +## 使用方法 + +### 基本语音指令 + +#### 1. 查询当前位置天气 +``` +用户:天气怎么样? +用户:今天天气如何? +用户:现在天气情况 +``` + +#### 2. 查询指定城市天气 +``` +用户:北京天气 +用户:上海的天气怎么样? +用户:查询杭州天气 +用户:广州今天天气如何? +``` + +#### 3. 查询省份天气(自动使用省会城市) +``` +用户:山东天气 +用户:浙江省天气怎么样? +用户:四川的天气 +``` + +#### 4. 查询地标或区域天气 +``` +用户:西湖天气 # 自动解析为杭州 +用户:外滩天气 # 自动解析为上海 +用户:天安门天气 # 自动解析为北京 +``` + +### 返回信息格式 + +插件会返回以下信息: + +``` +您查询的位置是:北京 + +当前天气: 晴,26°C + +详细参数: + · 湿度: 45% + · 风力: 3级 + · 气压: 1013hPa + · 能见度: 10km + +未来7天预报: +今天: 晴,气温 18°C~28°C +明天: 多云,气温 16°C~25°C +后天: 小雨,气温 14°C~22°C +周四: 阴,气温 15°C~20°C +周五: 晴,气温 17°C~24°C +周六: 多云,气温 19°C~26°C +周日: 晴,气温 21°C~28°C + +(如需某一天的具体天气,请告诉我日期) +``` + +## 支持的天气类型 + +插件支持以下天气现象的识别和显示: + +### 晴天类型 +- 晴 +- 多云 +- 少云 +- 晴间多云 + +### 阴雨类型 +- 阴 +- 阵雨、强阵雨 +- 雷阵雨、强雷阵雨 +- 雷阵雨伴有冰雹 +- 小雨、中雨、大雨 +- 极端降雨、暴雨、大暴雨、特大暴雨 +- 毛毛雨/细雨、冻雨 + +### 雪天类型 +- 小雪、中雪、大雪、暴雪 +- 雨夹雪、雨雪天气 +- 阵雨夹雪、阵雪 + +### 特殊天气 +- 薄雾、雾、浓雾 +- 霾、中度霾、重度霾、严重霾 +- 扬沙、浮尘、沙尘暴、强沙尘暴 + +## 智能位置解析 + +### 1. IP地址定位 +- 自动获取用户设备IP地址 +- 解析IP对应的城市信息 +- 缓存IP位置信息,提高响应速度 + +### 2. 地点名称智能匹配 +- **城市名**:直接查询该城市天气 +- **省份名**:自动使用省会城市 +- **地标名**:解析到所在城市 +- **区县名**:解析到所属市级城市 + +### 3. 默认位置机制 +- 当无法识别用户位置时,使用配置的默认城市 +- 建议设置为用户常居住的城市 + +## 缓存机制 + +### 1. IP位置缓存 +- 缓存用户IP对应的城市信息 +- 避免重复调用IP定位API +- 提高查询响应速度 + +### 2. 天气数据缓存 +- 缓存完整的天气报告 +- 缓存时间:通常为10-30分钟 +- 减少API调用次数,节省配额 + +## 故障排除 + +### 1. 查询失败 + +**现象**:提示"未找到相关的城市" + +**原因及解决方案**: +- 检查城市名称拼写是否正确 +- 尝试使用标准城市名称(如"北京"而非"北京市") +- 检查API Key是否有效 +- 确认网络连接正常 + +### 2. API调用超限 + +**现象**:返回API错误信息 + +**解决方案**: +- 检查和风天气控制台的配额使用情况 +- 考虑升级到付费计划 +- 优化缓存策略,减少API调用 + +### 3. 网络连接问题 + +**现象**:查询超时或连接失败 + +**解决方案**: +- 检查服务器网络连接 +- 确认和风天气API服务状态 +- 检查防火墙设置 + +### 4. 配置问题 + +**现象**:插件无法正常工作 + +**解决方案**: +```yaml +# 检查配置格式是否正确 +plugins: + get_weather: + api_host: "你的API主机地址" + api_key: "你的API密钥" + default_location: "默认城市" +``` + +## 高级配置 + +### 1. 自定义缓存时间 + +修改缓存管理器配置: + +```python +# 在 core/utils/cache/manager.py 中调整缓存时间 +WEATHER_CACHE_DURATION = 1800 # 30分钟 +``` + +### 2. 多语言支持 + +插件支持多语言查询: + +```yaml +# 在函数调用时指定语言 +get_weather(location="北京", lang="en_US") # 英文 +get_weather(location="北京", lang="ja_JP") # 日文 +``` + +### 3. 自定义默认位置 + +根据用户群体设置合适的默认城市: + +```yaml +plugins: + get_weather: + default_location: "上海" # 针对华东用户 + # default_location: "深圳" # 针对华南用户 + # default_location: "成都" # 针对西南用户 +``` + +## 注意事项 + +1. **API配额管理**:合理使用免费配额,避免频繁查询 +2. **地点准确性**:使用标准的城市名称获得最准确的结果 +3. **网络依赖**:功能依赖网络连接,确保服务器能访问外网 +4. **隐私保护**:IP定位信息仅用于天气查询,不会存储个人隐私 +5. **数据准确性**:天气数据来源于和风天气,准确性以官方为准 + +## 更新记录 + +- **v1.0.0**:基础天气查询功能 +- **v1.1.0**:增加IP自动定位功能 +- **v1.2.0**:优化缓存机制,提升查询速度 +- **v1.3.0**:支持7天天气预报 +- **v1.4.0**:智能地点解析,支持地标查询 + +## 相关链接 + +- [和风天气官网](https://www.qweather.com/) +- [和风天气开发文档](https://dev.qweather.com/) +- [API Key申请](https://console.qweather.com/#/apps/create-key/over) +- [项目GitHub](https://github.com/xinnan-tech/xiaozhi-esp32-server) + +--- + +*如有问题或建议,请在项目GitHub仓库提交Issue或联系开发团队。* From a842f0688f0ca3add677e30c110d22415d8a537d Mon Sep 17 00:00:00 2001 From: 3030332422 <3030332422@qq.com> Date: Tue, 2 Sep 2025 12:36:04 +0800 Subject: [PATCH 09/13] =?UTF-8?q?fix:=E4=BC=98=E5=8C=96=E5=A4=A9=E6=B0=94?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E6=8C=87=E5=8D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/weather-integration.md | 263 +++--------------------------------- 1 file changed, 16 insertions(+), 247 deletions(-) diff --git a/docs/weather-integration.md b/docs/weather-integration.md index 021b3f90..c13446fc 100644 --- a/docs/weather-integration.md +++ b/docs/weather-integration.md @@ -4,38 +4,6 @@ 天气插件 `get_weather` 是小智ESP32语音助手的核心功能之一,支持通过语音查询全国各地的天气信息。插件基于和风天气API,提供实时天气和7天天气预报功能。 -## 功能特性 - -- **智能位置识别**:根据用户IP自动识别所在城市 -- **多地点查询**:支持全国各城市、地区天气查询 -- **详细天气信息**:包含当前天气、温度、湿度、风力等详细参数 -- **7天预报**:提供未来一周的天气趋势 -- **缓存机制**:优化查询速度,减少API调用 -- **智能解析**:支持省份、城市、地标等多种地点表达方式 - -## 配置方式 - -### 1. 通过Web管理界面配置(推荐) - -1. 登录智控台 -2. 进入"角色配置"页面 -3. 选择要配置的智能体 -4. 点击"编辑功能"按钮 -5. 在右侧参数配置区域找到"天气查询"插件 -6. 配置相关参数(天气插件API密钥、默认查询城市等) - -### 2. 配置文件方式 - -在 `config.yaml` 中配置: - -```yaml -plugins: - get_weather: - api_host: "你的和风天气API主机地址" # 和风天气API主机地址 - api_key: "你的和风天气API密钥" # 必填:API密钥 - default_location: "广州" # 默认查询城市 -``` - ## API Key 申请指南 ### 1. 注册和风天气账号 @@ -46,11 +14,11 @@ plugins: ### 2. 创建应用获取API Key -1. 进入控制台后,点击右侧"项目管理" → "创建项目" +1. 进入控制台后,点击右侧"项目管理"(https://console.qweather.com/project?lang=zh) → "创建项目" 2. 填写项目信息: - **项目名称**:如"小智语音助手" 3. 点击保存 -4. 创建完成后,在该项目中点击"创建凭据" +4. 项目创建完成后,在该项目中点击"创建凭据" 5. 填写凭据信息: - **凭据名称**:如"小智语音助手" - **身份认证方式**:选择"API Key" @@ -59,7 +27,7 @@ plugins: ### 3. 获取API Host -1. 在控制台中点击"设置" → "API Host" +1. 在控制台中点击"设置"(https://console.qweather.com/setting?lang=zh) → "API Host" 2. 查看分配给你的专属API Host地址 3. 将地址复制到配置文件的 `api_host` 字段 @@ -69,224 +37,25 @@ plugins: - **付费计划**:超出免费额度后按调用次数计费 - **建议**:个人使用免费额度通常足够 -## 使用方法 +## 配置方式(任选一种) -### 基本语音指令 +### 方式1. 通过Web管理界面配置(推荐) -#### 1. 查询当前位置天气 -``` -用户:天气怎么样? -用户:今天天气如何? -用户:现在天气情况 -``` +1. 登录智控台 +2. 进入"角色配置"页面 +3. 选择要配置的智能体 +4. 点击"编辑功能"按钮 +5. 在右侧参数配置区域找到"天气查询"插件 +6. 配置相关参数(天气插件API密钥、默认查询城市等) -#### 2. 查询指定城市天气 -``` -用户:北京天气 -用户:上海的天气怎么样? -用户:查询杭州天气 -用户:广州今天天气如何? -``` +### 方式2. 配置文件方式 -#### 3. 查询省份天气(自动使用省会城市) -``` -用户:山东天气 -用户:浙江省天气怎么样? -用户:四川的天气 -``` - -#### 4. 查询地标或区域天气 -``` -用户:西湖天气 # 自动解析为杭州 -用户:外滩天气 # 自动解析为上海 -用户:天安门天气 # 自动解析为北京 -``` - -### 返回信息格式 - -插件会返回以下信息: - -``` -您查询的位置是:北京 - -当前天气: 晴,26°C - -详细参数: - · 湿度: 45% - · 风力: 3级 - · 气压: 1013hPa - · 能见度: 10km - -未来7天预报: -今天: 晴,气温 18°C~28°C -明天: 多云,气温 16°C~25°C -后天: 小雨,气温 14°C~22°C -周四: 阴,气温 15°C~20°C -周五: 晴,气温 17°C~24°C -周六: 多云,气温 19°C~26°C -周日: 晴,气温 21°C~28°C - -(如需某一天的具体天气,请告诉我日期) -``` - -## 支持的天气类型 - -插件支持以下天气现象的识别和显示: - -### 晴天类型 -- 晴 -- 多云 -- 少云 -- 晴间多云 - -### 阴雨类型 -- 阴 -- 阵雨、强阵雨 -- 雷阵雨、强雷阵雨 -- 雷阵雨伴有冰雹 -- 小雨、中雨、大雨 -- 极端降雨、暴雨、大暴雨、特大暴雨 -- 毛毛雨/细雨、冻雨 - -### 雪天类型 -- 小雪、中雪、大雪、暴雪 -- 雨夹雪、雨雪天气 -- 阵雨夹雪、阵雪 - -### 特殊天气 -- 薄雾、雾、浓雾 -- 霾、中度霾、重度霾、严重霾 -- 扬沙、浮尘、沙尘暴、强沙尘暴 - -## 智能位置解析 - -### 1. IP地址定位 -- 自动获取用户设备IP地址 -- 解析IP对应的城市信息 -- 缓存IP位置信息,提高响应速度 - -### 2. 地点名称智能匹配 -- **城市名**:直接查询该城市天气 -- **省份名**:自动使用省会城市 -- **地标名**:解析到所在城市 -- **区县名**:解析到所属市级城市 - -### 3. 默认位置机制 -- 当无法识别用户位置时,使用配置的默认城市 -- 建议设置为用户常居住的城市 - -## 缓存机制 - -### 1. IP位置缓存 -- 缓存用户IP对应的城市信息 -- 避免重复调用IP定位API -- 提高查询响应速度 - -### 2. 天气数据缓存 -- 缓存完整的天气报告 -- 缓存时间:通常为10-30分钟 -- 减少API调用次数,节省配额 - -## 故障排除 - -### 1. 查询失败 - -**现象**:提示"未找到相关的城市" - -**原因及解决方案**: -- 检查城市名称拼写是否正确 -- 尝试使用标准城市名称(如"北京"而非"北京市") -- 检查API Key是否有效 -- 确认网络连接正常 - -### 2. API调用超限 - -**现象**:返回API错误信息 - -**解决方案**: -- 检查和风天气控制台的配额使用情况 -- 考虑升级到付费计划 -- 优化缓存策略,减少API调用 - -### 3. 网络连接问题 - -**现象**:查询超时或连接失败 - -**解决方案**: -- 检查服务器网络连接 -- 确认和风天气API服务状态 -- 检查防火墙设置 - -### 4. 配置问题 - -**现象**:插件无法正常工作 - -**解决方案**: -```yaml -# 检查配置格式是否正确 -plugins: - get_weather: - api_host: "你的API主机地址" - api_key: "你的API密钥" - default_location: "默认城市" -``` - -## 高级配置 - -### 1. 自定义缓存时间 - -修改缓存管理器配置: - -```python -# 在 core/utils/cache/manager.py 中调整缓存时间 -WEATHER_CACHE_DURATION = 1800 # 30分钟 -``` - -### 2. 多语言支持 - -插件支持多语言查询: - -```yaml -# 在函数调用时指定语言 -get_weather(location="北京", lang="en_US") # 英文 -get_weather(location="北京", lang="ja_JP") # 日文 -``` - -### 3. 自定义默认位置 - -根据用户群体设置合适的默认城市: +在 `config.yaml` 中配置: ```yaml plugins: get_weather: - default_location: "上海" # 针对华东用户 - # default_location: "深圳" # 针对华南用户 - # default_location: "成都" # 针对西南用户 + api_host: "你的和风天气API主机地址" # 和风天气API主机地址 + api_key: "你的和风天气API密钥" # 必填:API密钥 + default_location: "你的默认查询城市" # 默认查询城市 ``` - -## 注意事项 - -1. **API配额管理**:合理使用免费配额,避免频繁查询 -2. **地点准确性**:使用标准的城市名称获得最准确的结果 -3. **网络依赖**:功能依赖网络连接,确保服务器能访问外网 -4. **隐私保护**:IP定位信息仅用于天气查询,不会存储个人隐私 -5. **数据准确性**:天气数据来源于和风天气,准确性以官方为准 - -## 更新记录 - -- **v1.0.0**:基础天气查询功能 -- **v1.1.0**:增加IP自动定位功能 -- **v1.2.0**:优化缓存机制,提升查询速度 -- **v1.3.0**:支持7天天气预报 -- **v1.4.0**:智能地点解析,支持地标查询 - -## 相关链接 - -- [和风天气官网](https://www.qweather.com/) -- [和风天气开发文档](https://dev.qweather.com/) -- [API Key申请](https://console.qweather.com/#/apps/create-key/over) -- [项目GitHub](https://github.com/xinnan-tech/xiaozhi-esp32-server) - ---- - -*如有问题或建议,请在项目GitHub仓库提交Issue或联系开发团队。* From 0f0a0814b76c011b68e7c25e7b076a51efb77c22 Mon Sep 17 00:00:00 2001 From: 3030332422 <3030332422@qq.com> Date: Tue, 2 Sep 2025 12:45:51 +0800 Subject: [PATCH 10/13] =?UTF-8?q?fix=EF=BC=9A=E8=A1=A5=E5=85=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/weather-integration.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/weather-integration.md b/docs/weather-integration.md index c13446fc..4832e093 100644 --- a/docs/weather-integration.md +++ b/docs/weather-integration.md @@ -14,7 +14,7 @@ ### 2. 创建应用获取API Key -1. 进入控制台后,点击右侧"项目管理"(https://console.qweather.com/project?lang=zh) → "创建项目" +1. 进入控制台后,点击右侧["项目管理"](https://console.qweather.com/project?lang=zh) → "创建项目" 2. 填写项目信息: - **项目名称**:如"小智语音助手" 3. 点击保存 @@ -27,7 +27,7 @@ ### 3. 获取API Host -1. 在控制台中点击"设置"(https://console.qweather.com/setting?lang=zh) → "API Host" +1. 在控制台中点击["设置"](https://console.qweather.com/setting?lang=zh) → "API Host" 2. 查看分配给你的专属API Host地址 3. 将地址复制到配置文件的 `api_host` 字段 From 5db638c075143a4ef05236e0ece78774182ea97a Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Tue, 2 Sep 2025 14:04:42 +0800 Subject: [PATCH 11/13] Update weather-integration.md --- docs/weather-integration.md | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/docs/weather-integration.md b/docs/weather-integration.md index 4832e093..3b6ca2b6 100644 --- a/docs/weather-integration.md +++ b/docs/weather-integration.md @@ -23,39 +23,42 @@ - **凭据名称**:如"小智语音助手" - **身份认证方式**:选择"API Key" 6. 点击保存 -7. 在凭据中复制API Key到配置文件中 +7. 在凭据中复制`API Key`,这是第一个关键的配置信息 ### 3. 获取API Host 1. 在控制台中点击["设置"](https://console.qweather.com/setting?lang=zh) → "API Host" -2. 查看分配给你的专属API Host地址 -3. 将地址复制到配置文件的 `api_host` 字段 +2. 查看分配给你的专属`API Host`地址,这个是第二个关键的配置信息 -### 4. 额度说明 - -- **免费额度**:每天1000次免费调用 -- **付费计划**:超出免费额度后按调用次数计费 -- **建议**:个人使用免费额度通常足够 +以上操作,会得到两个重要的配置信息:`API Key`和`API Host` ## 配置方式(任选一种) -### 方式1. 通过Web管理界面配置(推荐) +### 方式1. 如果你使用了智控台部署(推荐) 1. 登录智控台 2. 进入"角色配置"页面 3. 选择要配置的智能体 4. 点击"编辑功能"按钮 5. 在右侧参数配置区域找到"天气查询"插件 -6. 配置相关参数(天气插件API密钥、默认查询城市等) +6. 勾选"天气查询" +7. 将复制过来的第一个关键配置`API Key`,填入到`天气插件 API 密钥`里 +8. 将复制过来的第二个关键配置`API Host`,填入到`开发者 API Host`里 +9. 保存配置,再保存智能体配置 -### 方式2. 配置文件方式 +### 方式2. 如果你只是单模块xiaozhi-server部署 -在 `config.yaml` 中配置: +在 `data/.config.yaml` 中配置: + +1. 将复制过来的第一个关键配置`API Key`,填入到`api_key`里 +2. 将复制过来的第二个关键配置`API Host`,填入到`api_host`里 +3. 将你所在的城市填入到`default_location`里,例如`广州` ```yaml plugins: get_weather: - api_host: "你的和风天气API主机地址" # 和风天气API主机地址 - api_key: "你的和风天气API密钥" # 必填:API密钥 - default_location: "你的默认查询城市" # 默认查询城市 + api_key: "你的和风天气API密钥" + api_host: "你的和风天气API主机地址" + default_location: "你的默认查询城市" ``` + From 79956ade97dd46cebe25020bba1b8001c742a48b Mon Sep 17 00:00:00 2001 From: Sakura-RanChen <1908198662@qq.com> Date: Tue, 2 Sep 2025 14:36:13 +0800 Subject: [PATCH 12/13] =?UTF-8?q?fix:=20=E5=8F=98=E9=87=8F=E5=90=8D?= =?UTF-8?q?=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/core/providers/tts/fishspeech.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/main/xiaozhi-server/core/providers/tts/fishspeech.py b/main/xiaozhi-server/core/providers/tts/fishspeech.py index bbf19164..83b3bc3f 100644 --- a/main/xiaozhi-server/core/providers/tts/fishspeech.py +++ b/main/xiaozhi-server/core/providers/tts/fishspeech.py @@ -143,8 +143,8 @@ class TTSProvider(TTSProviderBase): data = { "text": text, "references": [ - ServeReferenceAudio(audio=audio if audio else b"", text=text) - for text, audio in zip(ref_texts, byte_audios) + ServeReferenceAudio(audio=audio if audio else b"", text=ref_text) + for ref_text, audio in zip(ref_texts, byte_audios) ], "reference_id": self.reference_id, "normalize": self.normalize, From 3cfc1cf97a38d058eba2f56ecfde8481d4b18737 Mon Sep 17 00:00:00 2001 From: Chingfeng Li Date: Wed, 3 Sep 2025 09:43:34 +0800 Subject: [PATCH 13/13] =?UTF-8?q?textHandle.py=20=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E8=A7=A3=E8=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/core/handle/textHandle.py | 171 +----------------- .../handle/textHandler/abortMessageHandler.py | 16 ++ .../handle/textHandler/helloMessageHandler.py | 16 ++ .../handle/textHandler/iotMessageHandler.py | 20 ++ .../textHandler/listenMessageHandler.py | 63 +++++++ .../handle/textHandler/mcpMessageHandler.py | 20 ++ .../textHandler/serverMessageHandler.py | 92 ++++++++++ .../core/handle/textMessageHandler.py | 21 +++ .../core/handle/textMessageHandlerRegistry.py | 45 +++++ .../core/handle/textMessageProcessor.py | 41 +++++ .../core/handle/textMessageType.py | 11 ++ 11 files changed, 353 insertions(+), 163 deletions(-) create mode 100644 main/xiaozhi-server/core/handle/textHandler/abortMessageHandler.py create mode 100644 main/xiaozhi-server/core/handle/textHandler/helloMessageHandler.py create mode 100644 main/xiaozhi-server/core/handle/textHandler/iotMessageHandler.py create mode 100644 main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py create mode 100644 main/xiaozhi-server/core/handle/textHandler/mcpMessageHandler.py create mode 100644 main/xiaozhi-server/core/handle/textHandler/serverMessageHandler.py create mode 100644 main/xiaozhi-server/core/handle/textMessageHandler.py create mode 100644 main/xiaozhi-server/core/handle/textMessageHandlerRegistry.py create mode 100644 main/xiaozhi-server/core/handle/textMessageProcessor.py create mode 100644 main/xiaozhi-server/core/handle/textMessageType.py diff --git a/main/xiaozhi-server/core/handle/textHandle.py b/main/xiaozhi-server/core/handle/textHandle.py index 1bcf4545..b5e87783 100644 --- a/main/xiaozhi-server/core/handle/textHandle.py +++ b/main/xiaozhi-server/core/handle/textHandle.py @@ -1,169 +1,14 @@ -import json -import time -from core.handle.abortHandle import handleAbortMessage -from core.handle.helloHandle import handleHelloMessage -from core.providers.tools.device_mcp import handle_mcp_message -from core.utils.util import remove_punctuation_and_length, filter_sensitive_info -from core.handle.receiveAudioHandle import startToChat, handleAudioMessage -from core.handle.sendAudioHandle import send_stt_message, send_tts_message -from core.providers.tools.device_iot import handleIotDescriptors, handleIotStatus -from core.handle.reportHandle import enqueue_asr_report -import asyncio +from core.handle.textMessageHandlerRegistry import TextMessageHandlerRegistry +from core.handle.textMessageProcessor import TextMessageProcessor TAG = __name__ +# 全局处理器注册表 +message_registry = TextMessageHandlerRegistry() + +# 创建全局消息处理器实例 +message_processor = TextMessageProcessor(message_registry) async def handleTextMessage(conn, message): """处理文本消息""" - try: - msg_json = json.loads(message) - if isinstance(msg_json, int): - conn.logger.bind(tag=TAG).info(f"收到文本消息:{message}") - await conn.websocket.send(message) - return - if msg_json["type"] == "hello": - conn.logger.bind(tag=TAG).info(f"收到hello消息:{message}") - await handleHelloMessage(conn, msg_json) - elif msg_json["type"] == "abort": - conn.logger.bind(tag=TAG).info(f"收到abort消息:{message}") - await handleAbortMessage(conn) - elif msg_json["type"] == "listen": - conn.logger.bind(tag=TAG).info(f"收到listen消息:{message}") - if "mode" in msg_json: - conn.client_listen_mode = msg_json["mode"] - conn.logger.bind(tag=TAG).debug( - f"客户端拾音模式:{conn.client_listen_mode}" - ) - if msg_json["state"] == "start": - conn.client_have_voice = True - conn.client_voice_stop = False - elif msg_json["state"] == "stop": - conn.client_have_voice = True - conn.client_voice_stop = True - if len(conn.asr_audio) > 0: - await handleAudioMessage(conn, b"") - elif msg_json["state"] == "detect": - conn.client_have_voice = False - conn.asr_audio.clear() - if "text" in msg_json: - conn.last_activity_time = time.time() * 1000 - original_text = msg_json["text"] # 保留原始文本 - filtered_len, filtered_text = remove_punctuation_and_length( - original_text - ) - - # 识别是否是唤醒词 - is_wakeup_words = filtered_text in conn.config.get("wakeup_words") - # 是否开启唤醒词回复 - enable_greeting = conn.config.get("enable_greeting", True) - - if is_wakeup_words and not enable_greeting: - # 如果是唤醒词,且关闭了唤醒词回复,就不用回答 - await send_stt_message(conn, original_text) - await send_tts_message(conn, "stop", None) - conn.client_is_speaking = False - elif is_wakeup_words: - conn.just_woken_up = True - # 上报纯文字数据(复用ASR上报功能,但不提供音频数据) - enqueue_asr_report(conn, "嘿,你好呀", []) - await startToChat(conn, "嘿,你好呀") - else: - # 上报纯文字数据(复用ASR上报功能,但不提供音频数据) - enqueue_asr_report(conn, original_text, []) - # 否则需要LLM对文字内容进行答复 - await startToChat(conn, original_text) - elif msg_json["type"] == "iot": - conn.logger.bind(tag=TAG).info(f"收到iot消息:{message}") - if "descriptors" in msg_json: - asyncio.create_task(handleIotDescriptors(conn, msg_json["descriptors"])) - if "states" in msg_json: - asyncio.create_task(handleIotStatus(conn, msg_json["states"])) - elif msg_json["type"] == "mcp": - conn.logger.bind(tag=TAG).info(f"收到mcp消息:{message[:100]}") - if "payload" in msg_json: - asyncio.create_task( - handle_mcp_message(conn, conn.mcp_client, msg_json["payload"]) - ) - elif msg_json["type"] == "server": - # 记录日志时过滤敏感信息 - conn.logger.bind(tag=TAG).info( - f"收到服务器消息:{filter_sensitive_info(msg_json)}" - ) - # 如果配置是从API读取的,则需要验证secret - if not conn.read_config_from_api: - return - # 获取post请求的secret - post_secret = msg_json.get("content", {}).get("secret", "") - secret = conn.config["manager-api"].get("secret", "") - # 如果secret不匹配,则返回 - if post_secret != secret: - await conn.websocket.send( - json.dumps( - { - "type": "server", - "status": "error", - "message": "服务器密钥验证失败", - } - ) - ) - return - # 动态更新配置 - if msg_json["action"] == "update_config": - try: - # 更新WebSocketServer的配置 - if not conn.server: - await conn.websocket.send( - json.dumps( - { - "type": "server", - "status": "error", - "message": "无法获取服务器实例", - "content": {"action": "update_config"}, - } - ) - ) - return - - if not await conn.server.update_config(): - await conn.websocket.send( - json.dumps( - { - "type": "server", - "status": "error", - "message": "更新服务器配置失败", - "content": {"action": "update_config"}, - } - ) - ) - return - - # 发送成功响应 - await conn.websocket.send( - json.dumps( - { - "type": "server", - "status": "success", - "message": "配置更新成功", - "content": {"action": "update_config"}, - } - ) - ) - except Exception as e: - conn.logger.bind(tag=TAG).error(f"更新配置失败: {str(e)}") - await conn.websocket.send( - json.dumps( - { - "type": "server", - "status": "error", - "message": f"更新配置失败: {str(e)}", - "content": {"action": "update_config"}, - } - ) - ) - # 重启服务器 - elif msg_json["action"] == "restart": - await conn.handle_restart(msg_json) - else: - conn.logger.bind(tag=TAG).error(f"收到未知类型消息:{message}") - except json.JSONDecodeError: - await conn.websocket.send(message) + await message_processor.process_message(conn, message) diff --git a/main/xiaozhi-server/core/handle/textHandler/abortMessageHandler.py b/main/xiaozhi-server/core/handle/textHandler/abortMessageHandler.py new file mode 100644 index 00000000..dc540d24 --- /dev/null +++ b/main/xiaozhi-server/core/handle/textHandler/abortMessageHandler.py @@ -0,0 +1,16 @@ +from typing import Dict, Any + +from core.handle.abortHandle import handleAbortMessage +from core.handle.textMessageHandler import TextMessageHandler +from core.handle.textMessageType import TextMessageType + + +class AbortTextMessageHandler(TextMessageHandler): + """Abort消息处理器""" + + @property + def message_type(self) -> TextMessageType: + return TextMessageType.ABORT + + async def handle(self, conn, msg_json: Dict[str, Any]) -> None: + await handleAbortMessage(conn) diff --git a/main/xiaozhi-server/core/handle/textHandler/helloMessageHandler.py b/main/xiaozhi-server/core/handle/textHandler/helloMessageHandler.py new file mode 100644 index 00000000..1839814e --- /dev/null +++ b/main/xiaozhi-server/core/handle/textHandler/helloMessageHandler.py @@ -0,0 +1,16 @@ +from typing import Dict, Any + +from core.handle.helloHandle import handleHelloMessage +from core.handle.textMessageHandler import TextMessageHandler +from core.handle.textMessageType import TextMessageType + + +class HelloTextMessageHandler(TextMessageHandler): + """Hello消息处理器""" + + @property + def message_type(self) -> TextMessageType: + return TextMessageType.HELLO + + async def handle(self, conn, msg_json: Dict[str, Any]) -> None: + await handleHelloMessage(conn, msg_json) \ No newline at end of file diff --git a/main/xiaozhi-server/core/handle/textHandler/iotMessageHandler.py b/main/xiaozhi-server/core/handle/textHandler/iotMessageHandler.py new file mode 100644 index 00000000..335d08b0 --- /dev/null +++ b/main/xiaozhi-server/core/handle/textHandler/iotMessageHandler.py @@ -0,0 +1,20 @@ +import asyncio +from typing import Dict, Any + +from core.handle.textMessageHandler import TextMessageHandler +from core.handle.textMessageType import TextMessageType +from core.providers.tools.device_iot import handleIotStatus, handleIotDescriptors + + +class IotTextMessageHandler(TextMessageHandler): + """IOT消息处理器""" + + @property + def message_type(self) -> TextMessageType: + return TextMessageType.IOT + + async def handle(self, conn, msg_json: Dict[str, Any]) -> None: + if "descriptors" in msg_json: + asyncio.create_task(handleIotDescriptors(conn, msg_json["descriptors"])) + if "states" in msg_json: + asyncio.create_task(handleIotStatus(conn, msg_json["states"])) \ No newline at end of file diff --git a/main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py b/main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py new file mode 100644 index 00000000..97286dfe --- /dev/null +++ b/main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py @@ -0,0 +1,63 @@ +import time +from typing import Dict, Any + +from core.handle.receiveAudioHandle import handleAudioMessage, startToChat +from core.handle.reportHandle import enqueue_asr_report +from core.handle.sendAudioHandle import send_stt_message, send_tts_message +from core.handle.textMessageHandler import TextMessageHandler +from core.handle.textMessageType import TextMessageType +from core.utils.util import remove_punctuation_and_length + +TAG = __name__ + +class ListenTextMessageHandler(TextMessageHandler): + """Listen消息处理器""" + + @property + def message_type(self) -> TextMessageType: + return TextMessageType.LISTEN + + async def handle(self, conn, msg_json: Dict[str, Any]) -> None: + if "mode" in msg_json: + conn.client_listen_mode = msg_json["mode"] + conn.logger.bind(tag=TAG).debug( + f"客户端拾音模式:{conn.client_listen_mode}" + ) + if msg_json["state"] == "start": + conn.client_have_voice = True + conn.client_voice_stop = False + elif msg_json["state"] == "stop": + conn.client_have_voice = True + conn.client_voice_stop = True + if len(conn.asr_audio) > 0: + await handleAudioMessage(conn, b"") + elif msg_json["state"] == "detect": + conn.client_have_voice = False + conn.asr_audio.clear() + if "text" in msg_json: + conn.last_activity_time = time.time() * 1000 + original_text = msg_json["text"] # 保留原始文本 + filtered_len, filtered_text = remove_punctuation_and_length( + original_text + ) + + # 识别是否是唤醒词 + is_wakeup_words = filtered_text in conn.config.get("wakeup_words") + # 是否开启唤醒词回复 + enable_greeting = conn.config.get("enable_greeting", True) + + if is_wakeup_words and not enable_greeting: + # 如果是唤醒词,且关闭了唤醒词回复,就不用回答 + await send_stt_message(conn, original_text) + await send_tts_message(conn, "stop", None) + conn.client_is_speaking = False + elif is_wakeup_words: + conn.just_woken_up = True + # 上报纯文字数据(复用ASR上报功能,但不提供音频数据) + enqueue_asr_report(conn, "嘿,你好呀", []) + await startToChat(conn, "嘿,你好呀") + else: + # 上报纯文字数据(复用ASR上报功能,但不提供音频数据) + enqueue_asr_report(conn, original_text, []) + # 否则需要LLM对文字内容进行答复 + await startToChat(conn, original_text) \ No newline at end of file diff --git a/main/xiaozhi-server/core/handle/textHandler/mcpMessageHandler.py b/main/xiaozhi-server/core/handle/textHandler/mcpMessageHandler.py new file mode 100644 index 00000000..65876f24 --- /dev/null +++ b/main/xiaozhi-server/core/handle/textHandler/mcpMessageHandler.py @@ -0,0 +1,20 @@ +import asyncio +from typing import Dict, Any + +from core.handle.textMessageHandler import TextMessageHandler +from core.handle.textMessageType import TextMessageType +from core.providers.tools.device_mcp import handle_mcp_message + + +class McpTextMessageHandler(TextMessageHandler): + """MCP消息处理器""" + + @property + def message_type(self) -> TextMessageType: + return TextMessageType.MCP + + async def handle(self, conn, msg_json: Dict[str, Any]) -> None: + if "payload" in msg_json: + asyncio.create_task( + handle_mcp_message(conn, conn.mcp_client, msg_json["payload"]) + ) \ No newline at end of file diff --git a/main/xiaozhi-server/core/handle/textHandler/serverMessageHandler.py b/main/xiaozhi-server/core/handle/textHandler/serverMessageHandler.py new file mode 100644 index 00000000..b9a23588 --- /dev/null +++ b/main/xiaozhi-server/core/handle/textHandler/serverMessageHandler.py @@ -0,0 +1,92 @@ +import asyncio +import json +from typing import Dict, Any + +from core.handle.textMessageHandler import TextMessageHandler +from core.handle.textMessageType import TextMessageType +from core.providers.tools.device_mcp import handle_mcp_message + +TAG = __name__ + +class ServerTextMessageHandler(TextMessageHandler): + """MCP消息处理器""" + + @property + def message_type(self) -> TextMessageType: + return TextMessageType.SERVER + + async def handle(self, conn, msg_json: Dict[str, Any]) -> None: + # 如果配置是从API读取的,则需要验证secret + if not conn.read_config_from_api: + return + # 获取post请求的secret + post_secret = msg_json.get("content", {}).get("secret", "") + secret = conn.config["manager-api"].get("secret", "") + # 如果secret不匹配,则返回 + if post_secret != secret: + await conn.websocket.send( + json.dumps( + { + "type": "server", + "status": "error", + "message": "服务器密钥验证失败", + } + ) + ) + return + # 动态更新配置 + if msg_json["action"] == "update_config": + try: + # 更新WebSocketServer的配置 + if not conn.server: + await conn.websocket.send( + json.dumps( + { + "type": "server", + "status": "error", + "message": "无法获取服务器实例", + "content": {"action": "update_config"}, + } + ) + ) + return + + if not await conn.server.update_config(): + await conn.websocket.send( + json.dumps( + { + "type": "server", + "status": "error", + "message": "更新服务器配置失败", + "content": {"action": "update_config"}, + } + ) + ) + return + + # 发送成功响应 + await conn.websocket.send( + json.dumps( + { + "type": "server", + "status": "success", + "message": "配置更新成功", + "content": {"action": "update_config"}, + } + ) + ) + except Exception as e: + conn.logger.bind(tag=TAG).error(f"更新配置失败: {str(e)}") + await conn.websocket.send( + json.dumps( + { + "type": "server", + "status": "error", + "message": f"更新配置失败: {str(e)}", + "content": {"action": "update_config"}, + } + ) + ) + # 重启服务器 + elif msg_json["action"] == "restart": + await conn.handle_restart(msg_json) \ No newline at end of file diff --git a/main/xiaozhi-server/core/handle/textMessageHandler.py b/main/xiaozhi-server/core/handle/textMessageHandler.py new file mode 100644 index 00000000..f94a0bac --- /dev/null +++ b/main/xiaozhi-server/core/handle/textMessageHandler.py @@ -0,0 +1,21 @@ +from abc import abstractmethod, ABC +from typing import Dict, Any + +from core.handle.textMessageType import TextMessageType + +TAG = __name__ + + +class TextMessageHandler(ABC): + """消息处理器抽象基类""" + + @abstractmethod + async def handle(self, conn, msg_json: Dict[str, Any]) -> None: + """处理消息的抽象方法""" + pass + + @property + @abstractmethod + def message_type(self) -> TextMessageType: + """返回处理的消息类型""" + pass diff --git a/main/xiaozhi-server/core/handle/textMessageHandlerRegistry.py b/main/xiaozhi-server/core/handle/textMessageHandlerRegistry.py new file mode 100644 index 00000000..e90d7231 --- /dev/null +++ b/main/xiaozhi-server/core/handle/textMessageHandlerRegistry.py @@ -0,0 +1,45 @@ +from typing import Dict, Optional + +from core.handle.textHandler.abortMessageHandler import AbortTextMessageHandler +from core.handle.textHandler.helloMessageHandler import HelloTextMessageHandler +from core.handle.textHandler.iotMessageHandler import IotTextMessageHandler +from core.handle.textHandler.listenMessageHandler import ListenTextMessageHandler +from core.handle.textHandler.mcpMessageHandler import McpTextMessageHandler +from core.handle.textMessageHandler import TextMessageHandler +from core.handle.textHandler.serverMessageHandler import ServerTextMessageHandler + +TAG = __name__ + + +class TextMessageHandlerRegistry: + """消息处理器注册表""" + + def __init__(self): + self._handlers: Dict[str, TextMessageHandler] = {} + self._register_default_handlers() + + def _register_default_handlers(self) -> None: + """注册默认的消息处理器""" + handlers = [ + HelloTextMessageHandler(), + AbortTextMessageHandler(), + ListenTextMessageHandler(), + IotTextMessageHandler(), + McpTextMessageHandler(), + ServerTextMessageHandler(), + ] + + for handler in handlers: + self.register_handler(handler) + + def register_handler(self, handler: TextMessageHandler) -> None: + """注册消息处理器""" + self._handlers[handler.message_type.value] = handler + + def get_handler(self, message_type: str) -> Optional[TextMessageHandler]: + """获取消息处理器""" + return self._handlers.get(message_type) + + def get_supported_types(self) -> list: + """获取支持的消息类型""" + return list(self._handlers.keys()) diff --git a/main/xiaozhi-server/core/handle/textMessageProcessor.py b/main/xiaozhi-server/core/handle/textMessageProcessor.py new file mode 100644 index 00000000..0cae5e09 --- /dev/null +++ b/main/xiaozhi-server/core/handle/textMessageProcessor.py @@ -0,0 +1,41 @@ +import json + +from core.handle.textMessageHandlerRegistry import TextMessageHandlerRegistry + +TAG = __name__ + + +class TextMessageProcessor: + """消息处理器主类""" + + def __init__(self, registry: TextMessageHandlerRegistry): + self.registry = registry + + async def process_message(self, conn, message: str) -> None: + """处理消息的主入口""" + try: + # 解析JSON消息 + msg_json = json.loads(message) + + # 处理JSON消息 + if isinstance(msg_json, dict): + message_type = msg_json.get("type") + + # 记录日志 + conn.logger.bind(tag=TAG).info(f"收到{message_type}消息:{message}") + + # 获取并执行处理器 + handler = self.registry.get_handler(message_type) + if handler: + await handler.handle(conn, msg_json) + else: + conn.logger.bind(tag=TAG).error(f"收到未知类型消息:{message}") + # 处理纯数字消息 + elif isinstance(msg_json, int): + conn.logger.bind(tag=TAG).info(f"收到数字消息:{message}") + await conn.websocket.send(message) + + except json.JSONDecodeError: + # 非JSON消息直接转发 + conn.logger.bind(tag=TAG).error(f"解析到错误的消息:{message}") + await conn.websocket.send(message) diff --git a/main/xiaozhi-server/core/handle/textMessageType.py b/main/xiaozhi-server/core/handle/textMessageType.py new file mode 100644 index 00000000..53e71b71 --- /dev/null +++ b/main/xiaozhi-server/core/handle/textMessageType.py @@ -0,0 +1,11 @@ +from enum import Enum + + +class TextMessageType(Enum): + """消息类型枚举""" + HELLO = "hello" + ABORT = "abort" + LISTEN = "listen" + IOT = "iot" + MCP = "mcp" + SERVER = "server"