From 50e6e4817b74cf40fa7c9aa6ec294ab79cf24c1c Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Sat, 7 Jun 2025 22:08:22 +0800 Subject: [PATCH] =?UTF-8?q?update:=E4=BC=98=E5=8C=96=E5=94=A4=E9=86=92?= =?UTF-8?q?=E8=AF=8D=E7=AD=94=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xiaozhi-server/core/handle/helloHandle.py | 150 ++++++++---------- .../providers/tts/huoshan_double_stream.py | 9 +- .../core/providers/tts/minimax.py | 8 +- .../core/providers/tts/ttson.py | 6 +- main/xiaozhi-server/core/utils/wakeup_word.py | 143 +++++++++++++++++ 5 files changed, 221 insertions(+), 95 deletions(-) create mode 100644 main/xiaozhi-server/core/utils/wakeup_word.py diff --git a/main/xiaozhi-server/core/handle/helloHandle.py b/main/xiaozhi-server/core/handle/helloHandle.py index c41baba0..154a2096 100644 --- a/main/xiaozhi-server/core/handle/helloHandle.py +++ b/main/xiaozhi-server/core/handle/helloHandle.py @@ -12,19 +12,21 @@ from core.handle.mcpHandle import ( send_mcp_initialize_message, send_mcp_tools_list_request, ) - +from core.utils.wakeup_word import WakeupWordsConfig TAG = __name__ WAKEUP_CONFIG = { - "dir": "config/assets/", - "file_name": "wakeup_words", - "create_time": time.time(), - "refresh_time": 10, + "refresh_time": 5, "words": ["你好小智", "你好啊小智", "小智你好", "小智"], - "text": "", } +# 创建全局的唤醒词配置管理器 +wakeup_words_config = WakeupWordsConfig() + +# 用于防止并发调用wakeupWordsResponse的锁 +_wakeup_response_lock = asyncio.Lock() + async def handleHelloMessage(conn, msg_json): """处理hello消息""" @@ -55,96 +57,78 @@ async def checkWakeupWords(conn, text): enable_wakeup_words_response_cache = conn.config[ "enable_wakeup_words_response_cache" ] - """是否用的是非流式tts""" - if conn.tts and conn.tts.interface_type != InterfaceType.NON_STREAM: + + if not enable_wakeup_words_response_cache or not conn.tts: return False - """是否开启唤醒词加速""" - if not enable_wakeup_words_response_cache: + if conn.tts.interface_type != InterfaceType.NON_STREAM: return False - """检查是否是唤醒词""" + _, filtered_text = remove_punctuation_and_length(text) - if filtered_text in conn.config.get("wakeup_words"): - await send_stt_message(conn, text) + if filtered_text not in conn.config.get("wakeup_words"): + return False - file = getWakeupWordFile(WAKEUP_CONFIG["file_name"]) - if file is None: + await send_stt_message(conn, text) + + # 获取当前音色 + voice = getattr(conn.tts, "voice", "default") + + # 获取唤醒词回复配置 + response = wakeup_words_config.get_wakeup_response(voice) + + # 播放唤醒词回复 + conn.client_abort = False + conn.tts.tts_one_sentence( + conn, + ContentType.FILE, + content_file=response["file_path"], + content_detail=response["text"], + ) + + # 检查是否需要更新唤醒词回复 + if time.time() - response["time"] > WAKEUP_CONFIG["refresh_time"]: + if not _wakeup_response_lock.locked(): asyncio.create_task(wakeupWordsResponse(conn)) - return False - text_hello = WAKEUP_CONFIG["text"] - if not text_hello: - text_hello = text - if conn.tts is None: - return False - conn.tts.tts_one_sentence( - conn, ContentType.FILE, content_file=file, content_detail=text_hello - ) - if time.time() - WAKEUP_CONFIG["create_time"] > WAKEUP_CONFIG["refresh_time"]: - asyncio.create_task(wakeupWordsResponse(conn)) - return True - return False - - -def getWakeupWordFile(file_name): - for file in os.listdir(WAKEUP_CONFIG["dir"]): - if file.startswith("my_" + file_name): - """避免缓存文件是一个空文件""" - if os.stat(f"config/assets/{file}").st_size > (15 * 1024): - return f"config/assets/{file}" - - """查找config/assets/目录下名称为wakeup_words的文件""" - for file in os.listdir(WAKEUP_CONFIG["dir"]): - if file.startswith(file_name): - return f"config/assets/{file}" - return None + return True async def wakeupWordsResponse(conn): - wait_max_time = 5 - while conn.llm is None or not conn.llm.response_no_stream: - await asyncio.sleep(1) - wait_max_time -= 1 - if wait_max_time <= 0: - conn.logger.bind(tag=TAG).error("连接对象没有llm") - return - - """唤醒词响应""" - wakeup_word = random.choice(WAKEUP_CONFIG["words"]) - question = ( - "此刻用户正在和你说```" - + wakeup_word - + "```。\n请你根据以上用户的内容,进行简短回复,文字内容控制在15个字以内。\n" - + "请勿对这条内容本身进行任何解释和回应,仅返回对用户的内容的回复。" - ) - result = conn.llm.response_no_stream(conn.config["prompt"], question) - if result is None or result == "": + if not conn.tts or not conn.llm or not conn.llm.response_no_stream: return - tts_result = await asyncio.to_thread(conn.tts.to_tts, result) - if conn.tts.delete_audio_file: - # 返回的是opus数据流 + try: + # 尝试获取锁,如果获取不到就返回 + if not await _wakeup_response_lock.acquire(): + return + + # 生成唤醒词回复 + wakeup_word = random.choice(WAKEUP_CONFIG["words"]) + question = ( + "此刻用户正在和你说```" + + wakeup_word + + "```。\n请你根据以上用户的内容进行简短回复。要像一个人正常人一样说话,不要像机器人一样说话。\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 = os.path.join( - WAKEUP_CONFIG["dir"], "my_" + WAKEUP_CONFIG["file_name"] + ".wav" - ) + file_path = wakeup_words_config.generate_file_path(voice) with open(file_path, "wb") as f: f.write(wav_bytes) - - else: - # 返回为文件 - if tts_result is not None and os.path.exists(tts_result): - file_type = os.path.splitext(tts_result)[1] - if file_type: - file_type = file_type.lstrip(".") - old_file = getWakeupWordFile("my_" + WAKEUP_CONFIG["file_name"]) - if old_file is not None: - os.remove(old_file) - """将文件挪到wakeup_words目录下""" - shutil.move( - tts_result, - WAKEUP_CONFIG["dir"] + "my_" + WAKEUP_CONFIG["file_name"] + '.' + file_type, - ) - WAKEUP_CONFIG["create_time"] = time.time() - WAKEUP_CONFIG["text"] = result + # 更新配置 + 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/providers/tts/huoshan_double_stream.py b/main/xiaozhi-server/core/providers/tts/huoshan_double_stream.py index 4d15204e..5193e19e 100644 --- a/main/xiaozhi-server/core/providers/tts/huoshan_double_stream.py +++ b/main/xiaozhi-server/core/providers/tts/huoshan_double_stream.py @@ -145,10 +145,9 @@ class TTSProvider(TTSProviderBase): self.cluster = config.get("cluster") self.resource_id = config.get("resource_id") if config.get("private_voice"): - self.speaker = config.get("private_voice") + self.voice = config.get("private_voice") else: - self.speaker = config.get("speaker") - self.voice = config.get("voice") + self.voice = config.get("speaker") self.ws_url = config.get("ws_url") self.authorization = config.get("authorization") self.header = {"Authorization": f"{self.authorization}{self.access_token}"} @@ -272,7 +271,7 @@ class TTSProvider(TTSProviderBase): logger.bind(tag=TAG).error(f"WebSocket连接不存在,终止发送文本") return # 发送文本 - await self.send_text(self.speaker, text, self.conn.sentence_id) + await self.send_text(self.voice, text, self.conn.sentence_id) return except Exception as e: logger.bind(tag=TAG).error(f"发送TTS文本失败: {str(e)}") @@ -302,7 +301,7 @@ class TTSProvider(TTSProviderBase): event=EVENT_StartSession, sessionId=session_id ).as_bytes() payload = self.get_payload_bytes( - event=EVENT_StartSession, speaker=self.speaker + event=EVENT_StartSession, speaker=self.voice ) await self.send_event(header, optional, payload) logger.bind(tag=TAG).info("会话启动请求已发送") diff --git a/main/xiaozhi-server/core/providers/tts/minimax.py b/main/xiaozhi-server/core/providers/tts/minimax.py index 3741d169..75e25e2e 100644 --- a/main/xiaozhi-server/core/providers/tts/minimax.py +++ b/main/xiaozhi-server/core/providers/tts/minimax.py @@ -14,9 +14,9 @@ class TTSProvider(TTSProviderBase): self.api_key = config.get("api_key") self.model = config.get("model") if config.get("private_voice"): - self.voice_id = config.get("private_voice") + self.voice = config.get("private_voice") else: - self.voice_id = config.get("voice_id") + self.voice = config.get("voice_id") default_voice_setting = { "voice_id": "female-shaonv", @@ -43,8 +43,8 @@ class TTSProvider(TTSProviderBase): self.audio_setting = {**defult_audio_setting, **config.get("audio_setting", {})} self.timber_weights = parse_string_to_list(config.get("timber_weights")) - if self.voice_id: - self.voice_setting["voice_id"] = self.voice_id + if self.voice: + self.voice_setting["voice_id"] = self.voice self.host = "api.minimax.chat" self.api_url = f"https://{self.host}/v1/t2a_v2?GroupId={self.group_id}" diff --git a/main/xiaozhi-server/core/providers/tts/ttson.py b/main/xiaozhi-server/core/providers/tts/ttson.py index c11206a6..91deeed9 100644 --- a/main/xiaozhi-server/core/providers/tts/ttson.py +++ b/main/xiaozhi-server/core/providers/tts/ttson.py @@ -19,9 +19,9 @@ class TTSProvider(TTSProviderBase): "https://u95167-bd74-2aef8085.westx.seetacloud.com:8443/flashsummary/tts?token=", ) if config.get("private_voice"): - self.voice_id = int(config.get("private_voice")) + self.voice = int(config.get("private_voice")) else: - self.voice_id = int(config.get("voice_id", 1695)) + self.voice = int(config.get("voice_id", 1695)) self.token = config.get("token") self.to_lang = config.get("to_lang") self.volume_change_dB = int(config.get("volume_change_dB", 0)) @@ -50,7 +50,7 @@ class TTSProvider(TTSProviderBase): "emotion": self.emotion, "format": self.format, "volume_change_dB": self.volume_change_dB, - "voice_id": self.voice_id, + "voice_id": self.voice, "pitch_factor": self.pitch_factor, "speed_factor": self.speed_factor, "token": self.token, 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..258f879b --- /dev/null +++ b/main/xiaozhi-server/core/utils/wakeup_word.py @@ -0,0 +1,143 @@ +import os +import yaml +import time +import hashlib +import fcntl +from typing import Dict +from contextlib import contextmanager + + +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: + fcntl.flock(self.file, fcntl.LOCK_EX | fcntl.LOCK_NB) + return self.file + except IOError: + if time.time() - self.start_time > self.timeout: + raise TimeoutError("获取文件锁超时") + time.sleep(0.1) + + def __exit__(self, exc_type, exc_val, exc_tb): + fcntl.flock(self.file, fcntl.LOCK_UN) + + +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() + default_response = { + "voice": "default", + "file_path": "config/assets/wakeup_words.wav", + "time": 0, + "text": "哈啰啊,我是小智啦,声音好听的台湾女孩一枚,超开心认识你耶,最近在忙啥,别忘了给我来点有趣的料哦,我超爱听八卦的啦", + } + + if not config or voice not in config: + return default_response + + # 检查文件大小 + file_path = config[voice]["file_path"] + if not os.path.exists(file_path) or os.stat(file_path).st_size < (15 * 1024): + return default_response + + return config[voice] + + def update_wakeup_response(self, voice: str, file_path: str, text: str): + """更新唤醒词回复配置""" + try: + config = self._load_config() + voice_hash = hashlib.md5(voice.encode()).hexdigest() + config[voice_hash] = { + "voice": voice, + "file_path": file_path, + "time": time.time(), + "text": 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