-
Notifications
You must be signed in to change notification settings - Fork 65
feat(character): add scenario voice trigger tags #181
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b904844
65d3731
cef1b52
6f4a857
ac170d3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -400,9 +400,14 @@ def delete_all_sprites(self, character_name: str) -> Tuple[str, List[str], str]: | |
| # 清空角色属性 | ||
| character.sprites = [] | ||
| character.emotion_tags = "" | ||
|
|
||
| # 场景语音也放在同一个 VOICE_DIR 下,目录已删,YAML 引用必须同步清空 | ||
| if hasattr(character, 'scenarios'): | ||
| for _sc in (character.scenarios or []): | ||
| _sc.voice_path = None | ||
| character.scenarios = [] | ||
|
|
||
| self._config_manager.save_characters_config() | ||
|
|
||
| return f"已删除 {character_name} 的所有立绘!", [], "" | ||
|
|
||
|
|
||
|
|
@@ -646,6 +651,124 @@ def save_sprite_scale(self, name: str, scale: float) -> str: | |
| return f"找不到角色: {name}" | ||
|
|
||
|
|
||
| # ---------- 情景模块 ---------- | ||
|
|
||
|
|
||
| def get_character_scenarios(self, character_name: str) -> List[dict]: | ||
| """获取角色的情景配置列表""" | ||
| character = self._config_manager.get_character_by_name(character_name) | ||
| if not character: | ||
| return [] | ||
| if hasattr(character, 'model_dump'): | ||
| return character.model_dump().get('scenarios', []) | ||
| return getattr(character, 'scenarios', []) or [] | ||
|
|
||
|
|
||
| def save_character_scenarios(self, character_name: str, scenarios: List[dict]) -> str: | ||
| """保存角色的完整情景配置(替换模式)""" | ||
| if not character_name: | ||
| return "请先选择角色!" | ||
| character = self._config_manager.get_character_by_name(character_name) | ||
| if not character: | ||
| return f"找不到角色: {character_name}" | ||
| from config.schema import CharacterScenario | ||
| validated = [CharacterScenario.model_validate(s) for s in scenarios] | ||
| character.scenarios = validated | ||
| self._config_manager.save_characters_config() | ||
| return "情景配置已保存" | ||
|
|
||
|
|
||
| def upload_scenario_voice( | ||
| self, character_name: str, scenario_index: int, | ||
| voice_file: str, voice_text: str, voice_type: str = "", | ||
| ) -> Tuple[str, Optional[str]]: | ||
| """为指定情景上传语音文件""" | ||
| if not character_name: | ||
| return "请先选择角色!", None | ||
| character = self._config_manager.get_character_by_name(character_name) | ||
| if not character: | ||
| return f"找不到角色: {character_name}", None | ||
| if not character.scenarios or scenario_index < 0 or scenario_index >= len(character.scenarios): | ||
| return "情景不存在!", None | ||
| scenario = character.scenarios[scenario_index] | ||
| if not voice_file: | ||
| return "请选择语音文件!", None | ||
|
|
||
| voice_dir = os.path.join(VOICE_DIR, character.sprite_prefix) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [MEDIUM · bug] 将场景音频与立绘清理生命周期隔离 / Isolate scenario audio from sprite cleanup 现有
置信度 98% · 可直接回复讨论 · 👍/👎 会影响后续审查 |
||
| Path(voice_dir).mkdir(parents=True, exist_ok=True) | ||
| file_ext = Path(voice_file).suffix | ||
| # 用场景名的 hash 做文件名,避免删标签后索引复用覆盖旧音频 | ||
| import re as _re | ||
| _safe = _re.sub(r'[^\w]', '_', scenario.name or f"tag{scenario_index}")[:40] | ||
| voice_filename = f"scenario_{_safe}{file_ext}" | ||
| voice_path = os.path.join(voice_dir, voice_filename) | ||
| shutil.copyfile(voice_file, voice_path) | ||
|
|
||
| scenario.voice_path = voice_path | ||
| scenario.voice_text = voice_text | ||
| if voice_type: | ||
| scenario.voice_type = voice_type | ||
|
|
||
| self._config_manager.save_characters_config() | ||
| return f"语音已上传到情景 {scenario_index+1}", voice_path | ||
|
|
||
|
|
||
| def delete_scenario_voice( | ||
| self, character_name: str, scenario_index: int, | ||
| ) -> str: | ||
| """删除指定情景的语音文件和引用""" | ||
| if not character_name: | ||
| return "请先选择角色!" | ||
| character = self._config_manager.get_character_by_name(character_name) | ||
| if not character: | ||
| return f"找不到角色: {character_name}" | ||
| if not character.scenarios or scenario_index < 0 or scenario_index >= len(character.scenarios): | ||
| return "情景不存在!" | ||
| scenario = character.scenarios[scenario_index] | ||
| # 先持久化清空引用,再删文件;防止中途崩溃导致 YAML 残留失效路径 | ||
| old_voice_path = scenario.voice_path | ||
| scenario.voice_path = None | ||
| scenario.voice_text = None | ||
| scenario.voice_type = None | ||
| self._config_manager.save_characters_config() | ||
| if old_voice_path: | ||
| try: | ||
| os.remove(old_voice_path) | ||
| except OSError: | ||
| pass | ||
| return "语音已删除" | ||
|
|
||
|
|
||
| def save_scenario_voice_text( | ||
| self, character_name: str, scenario_index: int, voice_text: str, | ||
| ) -> str: | ||
| """单独保存情景的语音文本""" | ||
| if not character_name: | ||
| return "请先选择角色!" | ||
| character = self._config_manager.get_character_by_name(character_name) | ||
| if not character: | ||
| return f"找不到角色: {character_name}" | ||
| scenario = character.scenarios[scenario_index] | ||
| scenario.voice_text = voice_text | ||
| self._config_manager.save_characters_config() | ||
| return "语音文本已保存" | ||
|
|
||
|
|
||
| def save_scenario_voice_type( | ||
| self, character_name: str, scenario_index: int, voice_type: str, | ||
| ) -> str: | ||
| """单独保存情景的语音类型(preset/reference)""" | ||
| if not character_name: | ||
| return "请先选择角色!" | ||
| character = self._config_manager.get_character_by_name(character_name) | ||
| if not character: | ||
| return f"找不到角色: {character_name}" | ||
| scenario = character.scenarios[scenario_index] | ||
| scenario.voice_type = voice_type if voice_type else None | ||
| self._config_manager.save_characters_config() | ||
| return "语音类型已保存" | ||
|
|
||
|
|
||
| def load_characters_from_file(self) -> Tuple[str, List[List[str]]]: | ||
| """ | ||
| 重新加载人物设定文件。 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,6 +23,7 @@ | |
| from sdk.messages import LLMDialogMessage, TTSOutputMessage | ||
| from core.runtime.app_runtime import get_app_runtime, tts_emit_to_ui_queue | ||
| from i18n import tr as tr_i18n | ||
| import re | ||
|
|
||
| _config = ConfigManager() | ||
|
|
||
|
|
@@ -31,6 +32,46 @@ def get_character_by_name(name: str): | |
| return _config.get_character_by_name(name) | ||
|
|
||
|
|
||
| def _read_scenarios_from_yaml(name_s: str) -> list: | ||
| """直接从 YAML 读取角色的 scenarios,避免跨进程缓存。""" | ||
| try: | ||
| _chars_path = Path("data/config/characters.yaml") | ||
| if not _chars_path.is_file(): | ||
| return [] | ||
| with open(_chars_path, "r", encoding="utf-8") as _fh: | ||
| _data = yaml.safe_load(_fh) or [] | ||
| for _c in _data: | ||
| if _c.get("name") == name_s: | ||
| _scenarios = _c.get("scenarios") or [] | ||
| # 归一化 voice_type 为小写,防御旧数据大小写不一致 | ||
| for _s in _scenarios: | ||
| if isinstance(_s, dict) and _s.get("voice_type"): | ||
| _s["voice_type"] = str(_s["voice_type"]).strip().lower() | ||
| return _scenarios | ||
| except Exception: | ||
| pass | ||
| return [] | ||
|
|
||
|
|
||
| def _match_voice_tag(speech: str, scenarios: list): | ||
| """检测台词中的 [tagname] 标签,匹配角色情景配置。 | ||
| 返回匹配到的 scenario dict,未匹配返回 None。 | ||
| 同时返回去除标签后的纯净台词。 | ||
| """ | ||
| if not speech or not scenarios: | ||
| return None, speech | ||
| tags = re.findall(r'\[(\w+)\]', speech) | ||
| if not tags: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [MEDIUM · correctness] 统一标签语法并保留词间空格 / Align tag grammar and preserve word spacing UI 和 schema 未限制标签名只能是 Original in EnglishThe UI/schema allow names beyond 置信度 97% · 可直接回复讨论 · 👍/👎 会影响后续审查 |
||
| return None, speech | ||
| for tag in tags: | ||
| for s in scenarios: | ||
| s_name = s.name if hasattr(s, 'name') else s.get('name', '') | ||
| if s_name.lower() == tag.lower(): | ||
| clean = re.sub(r'\s*\[\w+\]\s*', ' ', speech).strip() | ||
| return s, clean | ||
| return None, speech | ||
|
|
||
|
|
||
| def _post_tts_busy(text: str) -> None: | ||
| try: | ||
| get_app_runtime().ui_update_manager.post_busy_bar(text, 0.0) | ||
|
|
@@ -135,10 +176,42 @@ def handle(self, msg: LLMDialogMessage) -> None: | |
| asset_id = msg.asset_id | ||
| text_processor = rt.text_processor | ||
| speech_text = speech | ||
|
|
||
| # 语音触发标签:[tagname] 匹配角色情景,触发对应语音 | ||
| # 必须在翻译处理之前匹配,因为标签只存在于原始 speech 中 | ||
| _tag_ref_audio = None | ||
| _tag_prompt = None | ||
| _scenarios = _read_scenarios_from_yaml(name_s) | ||
| _matched, _clean_speech = _match_voice_tag(speech_text, _scenarios) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [MEDIUM · correctness] 翻译台词仍需从原文匹配标签 / Match tags from source speech when translation is enabled 模板规则要求把标签写入 Original in EnglishThe template places tags in 置信度 97% · 可直接回复讨论 · 👍/👎 会影响后续审查 |
||
| if _matched: | ||
| _m_name = _matched.name if hasattr(_matched, 'name') else _matched.get('name', '?') | ||
| _m_vt = _matched.voice_type if hasattr(_matched, 'voice_type') else _matched.get('voice_type') | ||
| _m_vp = _matched.voice_path if hasattr(_matched, 'voice_path') else _matched.get('voice_path') | ||
| _m_vtext = _matched.voice_text if hasattr(_matched, 'voice_text') else _matched.get('voice_text') | ||
| print(f"[VoiceTag] 匹配标签 [{_m_name}] type={_m_vt} vp={_m_vp}") | ||
| if _m_vt == "preset" and _m_vp: | ||
| print(f"[VoiceTag] 预设语音:跳过 TTS,直接播放 {_m_vp}") | ||
| audio_path = Path(_m_vp).resolve().as_posix() | ||
| _hide_tts_busy() | ||
| tts_emit_to_ui_queue( | ||
| name_s, _m_vtext or _clean_speech, str(asset_id), audio_path, | ||
| is_system_message=False, effect=msg.effect, | ||
| ) | ||
| return | ||
| elif _m_vt == "reference" and _m_vp: | ||
| print(f"[VoiceTag] 参考语音:用 {_m_vp} 作为 TTS 参考音色") | ||
| speech_text = _clean_speech | ||
| _tag_ref_audio = _m_vp | ||
| _tag_prompt = _m_vtext or "" | ||
|
|
||
| if translate: | ||
| text_processor = None | ||
| speech_text = rt.text_processor.remove_parentheses(translate) | ||
| speech_text = rt.text_processor.replace_names(speech_text) | ||
|
|
||
| # 剥离语音标签 [xxx],不显示在台词中 | ||
| speech_text = re.sub(r'\s*\[\w+\]\s*', ' ', speech_text).strip() | ||
|
|
||
| audio_path = "" | ||
| if rt.tts_manager: | ||
| _post_tts_busy(tr_i18n("desktop.tts_busy_synthesizing", name=name_s)) | ||
|
|
@@ -161,11 +234,23 @@ def handle(self, msg: LLMDialogMessage) -> None: | |
| prompt_text = character_config.prompt_text | ||
| try: | ||
| sprite_data = character_config.sprites[sprite_id] | ||
| if sprite_data.get("voice_text", None): | ||
| ref_audio_path = Path(sprite_data.get("voice_path")).resolve().as_posix() | ||
| prompt_text = sprite_data.get("voice_text") | ||
| if isinstance(sprite_data, dict): | ||
| _vt = sprite_data.get("voice_text") | ||
| else: | ||
| _vt = getattr(sprite_data, "voice_text", None) | ||
| if _vt: | ||
| if isinstance(sprite_data, dict): | ||
| ref_audio_path = Path(sprite_data.get("voice_path") or "").resolve().as_posix() | ||
| else: | ||
| ref_audio_path = Path(getattr(sprite_data, "voice_path", "") or "").resolve().as_posix() | ||
| prompt_text = _vt | ||
| except Exception: | ||
| print("没有立绘") | ||
| # 语音触发标签匹配到 reference 时,用标签配置覆盖参考音频 | ||
| if _tag_ref_audio: | ||
| ref_audio_path = Path(_tag_ref_audio).resolve().as_posix() | ||
| if _tag_prompt: | ||
| prompt_text = _tag_prompt | ||
| if text_processor: | ||
| speech_text = text_processor.remove_parentheses(speech_text) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| allowBuilds: | ||
| esbuild: true |
Uh oh!
There was an error while loading. Please reload this page.