diff --git a/src/plugins/built_in/tts_voice_plugin/actions/tts_action.py b/src/plugins/built_in/tts_voice_plugin/actions/tts_action.py index 9ea6a87f3..795891c02 100644 --- a/src/plugins/built_in/tts_voice_plugin/actions/tts_action.py +++ b/src/plugins/built_in/tts_voice_plugin/actions/tts_action.py @@ -2,8 +2,10 @@ TTS 语音合成 Action """ +import toml +from pathlib import Path + from src.common.logger import get_logger -from src.plugin_system.apis import generator_api from src.plugin_system.base.base_action import ActionActivationType, BaseAction, ChatMode from ..services.manager import get_service @@ -11,24 +13,96 @@ from ..services.manager import get_service logger = get_logger("tts_voice_plugin.action") +def _get_available_styles() -> list[str]: + """动态读取配置文件,获取所有可用的TTS风格名称""" + try: + # 这个路径构建逻辑是为了确保无论从哪里启动,都能准确定位到配置文件 + plugin_file = Path(__file__).resolve() + # Bot/src/plugins/built_in/tts_voice_plugin/actions -> Bot + bot_root = plugin_file.parent.parent.parent.parent.parent.parent + config_file = bot_root / "config" / "plugins" / "tts_voice_plugin" / "config.toml" + + if not config_file.is_file(): + logger.warning("在 tts_action 中未找到 tts_voice_plugin 的配置文件,无法动态加载风格列表。") + return ["default"] + + config = toml.loads(config_file.read_text(encoding="utf-8")) + + styles_config = config.get("tts_styles", []) + if not isinstance(styles_config, list): + return ["default"] + + # 使用显式循环和类型检查来提取 style_name,以确保 Pylance 类型检查通过 + style_names: list[str] = [] + for style in styles_config: + if isinstance(style, dict): + name = style.get("style_name") + # 确保 name 是一个非空字符串 + if isinstance(name, str) and name: + style_names.append(name) + + return style_names if style_names else ["default"] + except Exception as e: + logger.error(f"动态加载TTS风格列表时出错: {e}", exc_info=True) + return ["default"] # 出现任何错误都回退 + + +# 在类定义之前执行函数,获取风格列表 +AVAILABLE_STYLES = _get_available_styles() +STYLE_OPTIONS_DESC = ", ".join(f"'{s}'" for s in AVAILABLE_STYLES) + + class TTSVoiceAction(BaseAction): """ 通过关键词或规划器自动触发 TTS 语音合成 """ action_name = "tts_voice_action" - action_description = "使用GPT-SoVITS将文本转换为语音并发送" + action_description = "将你生成好的文本转换为语音并发送。你必须提供要转换的文本。" mode_enable = ChatMode.ALL parallel_action = False + action_parameters = { + "text": { + "type": "string", + "description": "需要转换为语音并发送的完整、自然、适合口语的文本内容。", + "required": True + }, + "voice_style": { + "type": "string", + "description": f"语音的风格。可用选项: [{STYLE_OPTIONS_DESC}]。请根据对话的情感和上下文选择一个最合适的风格。如果未提供,将使用默认风格。", + "required": False + }, + "text_language": { + "type": "string", + "description": ( + "指定用于合成的语言模式,请务必根据文本内容选择最精确、范围最小的选项以获得最佳效果。" + "可用选项说明:\n" + "- 'zh': 中文与英文混合 (最优选)\n" + "- 'ja': 日文与英文混合 (最优选)\n" + "- 'yue': 粤语与英文混合 (最优选)\n" + "- 'ko': 韩文与英文混合 (最优选)\n" + "- 'en': 纯英文\n" + "- 'all_zh': 纯中文\n" + "- 'all_ja': 纯日文\n" + "- 'all_yue': 纯粤语\n" + "- 'all_ko': 纯韩文\n" + "- 'auto': 多语种混合自动识别 (备用选项,当前两种语言时优先使用上面的精确选项)\n" + "- 'auto_yue': 多语种混合自动识别(包含粤语)(备用选项)" + ), + "required": False + } + } + action_require = [ + "在调用此动作时,你必须在 'text' 参数中提供要合成语音的完整回复内容。这是强制性的。", "当用户明确请求使用语音进行回复时,例如‘发个语音听听’、‘用语音说’等。", "当对话内容适合用语音表达,例如讲故事、念诗、撒嬌或进行角色扮演时。", "在表达特殊情感(如安慰、鼓励、庆祝)的场景下,可以主动使用语音来增强感染力。", "不要在日常的、简短的问答或闲聊中频繁使用语音,避免打扰用户。", - "文本内容必须是纯粹的对话,不能包含任何括号或方括号括起来的动作、表情、或场景描述(例如,不要出现 '(笑)' 或 '[歪头]')", - "必须使用标准、完整的标点符号(如逗号、句号、问号)来进行自然的断句,以确保语音停顿自然,避免生成一长串没有停顿的文本。" + "提供的 'text' 内容必须是纯粹的对话,不能包含任何括号或方括号括起来的动作、表情、或场景描述(例如,不要出现 '(笑)' 或 '[歪头]')", + "【**铁则**】为了确保语音停顿自然,'text' 参数中的所有断句【必须】使用且仅能使用以下标准标点符号:','、'。'、'?'、'!'。严禁使用 '~'、'...' 或其他任何非标准符号来分隔句子,否则将导致语音合成失败。" ] def __init__(self, *args, **kwargs): @@ -80,16 +154,23 @@ class TTSVoiceAction(BaseAction): initial_text = self.action_data.get("text", "").strip() voice_style = self.action_data.get("voice_style", "default") - logger.info(f"{self.log_prefix} 接收到规划器的初步文本: '{initial_text[:70]}...'") + # 新增:从决策模型获取指定的语言模式 + text_language = self.action_data.get("text_language") # 如果模型没给,就是 None + logger.info(f"{self.log_prefix} 接收到规划器初步文本: '{initial_text[:70]}...', 指定风格: {voice_style}, 指定语言: {text_language}") - # 1. 请求主回复模型生成高质量文本 - text = await self._generate_final_text(initial_text) + # 1. 使用规划器提供的文本 + text = initial_text if not text: - logger.warning(f"{self.log_prefix} 最终生成的文本为空,静默处理。") - return False, "最终生成的文本为空" + logger.warning(f"{self.log_prefix} 规划器提供的文本为空,静默处理。") + return False, "规划器提供的文本为空" # 2. 调用 TTSService 生成语音 - audio_b64 = await self.tts_service.generate_voice(text, voice_style) + logger.info(f"{self.log_prefix} 使用最终文本进行语音合成: '{text[:70]}...'") + audio_b64 = await self.tts_service.generate_voice( + text=text, + style_hint=voice_style, + language_hint=text_language # 新增:将决策模型指定的语言传递给服务 + ) if audio_b64: await self.send_custom(message_type="voice", content=audio_b64) @@ -115,33 +196,3 @@ class TTSVoiceAction(BaseAction): ) return False, f"语音合成出错: {e!s}" - async def _generate_final_text(self, initial_text: str) -> str: - """请求主回复模型生成或优化文本""" - try: - generation_reason = ( - "这是一个为语音消息(TTS)生成文本的特殊任务。" - "请基于规划器提供的初步文本,结合对话历史和自己的人设,将它优化成一句自然、富有感情、适合用语音说出的话。" - "最终指令:请务-必确保文本听起来像真实的、自然的口语对话,而不是书面语。" - ) - - logger.info(f"{self.log_prefix} 请求主回复模型(replyer)全新生成TTS文本...") - success, response_set, _ = await generator_api.rewrite_reply( - chat_stream=self.chat_stream, - reply_data={"raw_reply": initial_text, "reason": generation_reason}, - request_type="replyer" - ) - - if success and response_set: - text = "".join(str(seg[1]) if isinstance(seg, tuple) else str(seg) for seg in response_set).strip() - logger.info(f"{self.log_prefix} 成功生成高质量TTS文本: {text}") - return text - - if initial_text: - logger.warning(f"{self.log_prefix} 主模型生成失败,使用规划器原始文本作为兜底。") - return initial_text - - raise Exception("主模型未能生成回复,且规划器也未提供兜底文本。") - - except Exception as e: - logger.error(f"{self.log_prefix} 生成高质量回复内容时失败: {e}", exc_info=True) - return "" diff --git a/src/plugins/built_in/tts_voice_plugin/services/tts_service.py b/src/plugins/built_in/tts_voice_plugin/services/tts_service.py index c00eb31dd..d11dbd925 100644 --- a/src/plugins/built_in/tts_voice_plugin/services/tts_service.py +++ b/src/plugins/built_in/tts_voice_plugin/services/tts_service.py @@ -80,21 +80,34 @@ class TTSService: "prompt_language": style_cfg.get("prompt_language", "zh"), "gpt_weights": style_cfg.get("gpt_weights", default_gpt_weights), "sovits_weights": style_cfg.get("sovits_weights", default_sovits_weights), - "speed_factor": style_cfg.get("speed_factor"), # 读取独立的语速配置 + "speed_factor": style_cfg.get("speed_factor"), + "text_language": style_cfg.get("text_language", "auto"), # 新增:读取文本语言模式 } return styles - # ... [其他方法保持不变] ... - def _detect_language(self, text: str) -> str: - chinese_chars = len(re.findall(r"[\u4e00-\u9fff]", text)) - english_chars = len(re.findall(r"[a-zA-Z]", text)) + def _determine_final_language(self, text: str, mode: str) -> str: + """根据配置的语言策略和文本内容,决定最终发送给API的语言代码""" + # 如果策略是具体的语言(如 all_zh, ja),直接使用 + if mode not in ["auto", "auto_yue"]: + return mode + + # 对于 auto 和 auto_yue 策略,进行内容检测 + # 优先检测粤语 + if mode == "auto_yue": + cantonese_keywords = ["嘅", "喺", "咗", "唔", "係", "啲", "咩", "乜", "喂"] + if any(keyword in text for keyword in cantonese_keywords): + logger.info("在 auto_yue 模式下检测到粤语关键词,最终语言: yue") + return "yue" + + # 检测日语(简单启发式规则) japanese_chars = len(re.findall(r"[\u3040-\u309f\u30a0-\u30ff]", text)) - total_chars = chinese_chars + english_chars + japanese_chars - if total_chars == 0: return "zh" - if chinese_chars / total_chars > 0.3: return "zh" - elif japanese_chars / total_chars > 0.3: return "ja" - elif english_chars / total_chars > 0.8: return "en" - else: return "zh" + if japanese_chars > 5 and japanese_chars > len(re.findall(r"[\u4e00-\u9fff]", text)) * 0.5: + logger.info("检测到日语字符,最终语言: ja") + return "ja" + + # 默认回退到中文 + logger.info(f"在 {mode} 模式下未检测到特定语言,默认回退到: zh") + return "zh" def _clean_text_for_tts(self, text: str) -> str: # 1. 基本清理 @@ -259,7 +272,7 @@ class TTSService: logger.error(f"应用空间效果时出错: {e}", exc_info=True) return audio_data # 如果出错,返回原始音频 - async def generate_voice(self, text: str, style_hint: str = "default") -> str | None: + async def generate_voice(self, text: str, style_hint: str = "default", language_hint: str | None = None) -> str | None: self._load_config() if not self.tts_styles: @@ -282,11 +295,21 @@ class TTSService: clean_text = self._clean_text_for_tts(text) if not clean_text: return None - text_language = self._detect_language(clean_text) - logger.info(f"开始TTS语音合成,文本:{clean_text[:50]}..., 风格:{style}") + # 语言决策流程: + # 1. 优先使用决策模型直接指定的 language_hint (最高优先级) + if language_hint: + final_language = language_hint + logger.info(f"使用决策模型指定的语言: {final_language}") + else: + # 2. 如果模型未指定,则使用风格配置的 language_policy + language_policy = server_config.get("text_language", "auto") + final_language = self._determine_final_language(clean_text, language_policy) + logger.info(f"决策模型未指定语言,使用策略 '{language_policy}' -> 最终语言: {final_language}") + + logger.info(f"开始TTS语音合成,文本:{clean_text[:50]}..., 风格:{style}, 最终语言: {final_language}") audio_data = await self._call_tts_api( - server_config=server_config, text=clean_text, text_language=text_language, + server_config=server_config, text=clean_text, text_language=final_language, refer_wav_path=server_config.get("refer_wav_path"), prompt_text=server_config.get("prompt_text"), prompt_language=server_config.get("prompt_language"),