Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion config/character_config.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import yaml

class CharacterConfig:
def __init__(self, name, color, sprite_prefix, gpt_model_path=None, sovits_model_path=None, refer_audio_path=None, prompt_text=None, prompt_lang=None, sprites=[], emotion_tags="", sprite_scale=1.0, character_setting="", speech_speed=1.0, speech_volume=1.0, pronunciation_map=None):
def __init__(self, name, color, sprite_prefix, gpt_model_path=None, sovits_model_path=None, refer_audio_path=None, prompt_text=None, prompt_lang=None, sprites=[], emotion_tags="", sprite_scale=1.0, character_setting="", speech_speed=1.0, speech_volume=1.0, pronunciation_map=None, scenarios=None):
# 角色基本信息
self.name = name
self.color = color
Expand All @@ -10,6 +10,7 @@ def __init__(self, name, color, sprite_prefix, gpt_model_path=None, sovits_model
self.character_setting = character_setting
self.sprite_scale = sprite_scale
self.emotion_tags = emotion_tags
self.scenarios = scenarios or []
self.speech_speed = speech_speed
self.speech_volume = speech_volume
self.pronunciation_map = pronunciation_map or {}
Expand Down Expand Up @@ -65,6 +66,7 @@ def parse_dic(char_data):
speech_speed=char_data.get("speech_speed", 1.0),
speech_volume=char_data.get("speech_volume", 1.0),
pronunciation_map=char_data.get("pronunciation_map", None),
scenarios=char_data.get("scenarios", []),
Comment thread
V1per-cmyk marked this conversation as resolved.
)
return character

127 changes: 125 additions & 2 deletions config/character_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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} 的所有立绘!", [], ""


Expand Down Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM · bug] 将场景音频与立绘清理生命周期隔离 / Isolate scenario audio from sprite cleanup

现有 delete_all_sprites 会递归删除 VOICE_DIR/<sprite_prefix>,但只清空立绘和情绪标签,不清空 character.scenarios。把新情景音频放入该目录后,“清空全部立绘”会删除 preset/reference 文件却在 YAML 中保留 voice_path,下一次标签触发将使用不存在的音频或参考文件。

delete_all_sprites recursively removes VOICE_DIR/<sprite_prefix> without clearing scenarios. Storing scenario audio there makes Clear all sprites delete configured audio while YAML retains stale paths.

置信度 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]]]:
"""
重新加载人物设定文件。
Expand Down
12 changes: 11 additions & 1 deletion config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,28 @@ class Sprite(BaseModel):
voice_path: Optional[FilePath] = Field(None, description="对应的语音文件的路径 (可选)")
voice_text: Optional[str] = Field(None, description="语音对应的文本内容 (可选, 存在于某些条目中)")


class CharacterScenario(BaseModel):
"""角色的单个情景(情绪或动作),由 AI 自主识别"""
name: str = Field(..., description="情景名称(英文,如 happy / kiss / angry)")
voice_path: Optional[str] = Field(None, description="语音文件路径(可选)")
voice_type: Optional[str] = Field(None, description="语音类型: preset 或 reference")
voice_text: Optional[str] = Field(None, description="语音对应文本内容")


class Character(BaseModel):
"""单个角色配置的实体模型"""
# 角色基本信息
name: str = Field(..., description="角色名称")
color: str = Field(..., description="角色对话框或名字的颜色")
sprite_prefix: str = Field(..., description="立绘文件名的通用前缀")

# 列表中可能包含 Sprite 模型,也可能只是原始字典
sprites: List[Union[Sprite, dict]] = Field(default_factory=list, description="角色的立绘和对应语音的列表")
character_setting: DefaultIfNone[str] = Field(default="", description="角色背景、性格和语言习惯的详细描述")
sprite_scale: DefaultIfNone[float] = Field(default=1.0, description="立绘的缩放比例 (默认值 1.0)")
emotion_tags: DefaultIfNone[str] = Field(default="", description="情绪标签和对应的立绘编号描述")
scenarios: List[CharacterScenario] = Field(default_factory=list, description="情景配置列表(由 AI 自主识别情绪或动作)")

# gpt-sovits 相关的配置
gpt_model_path: Optional[str] = Field('', description="角色 GPT 模型的路径 (可选)")
Expand Down
91 changes: 88 additions & 3 deletions core/handlers/tts_message_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM · correctness] 统一标签语法并保留词间空格 / Align tag grammar and preserve word spacing

UI 和 schema 未限制标签名只能是 \w+,因此可保存的 sad-face 会被模板列出却永远无法由该正则匹配。并且同一清理逻辑中的 \s* 会同时吞掉标签两侧空白,使 I feel [sad] very tired 变成 I feelvery tired;后续 TTS/UI 清理也重复该行为。应统一标签语法校验与清理规则,并保留词间分隔。

Original in English

The UI/schema allow names beyond \w+, so a saved sad-face tag never matches. The surrounding \s* also joins words around mid-sentence tags, and the later TTS/UI cleaners repeat it.

置信度 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)
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM · correctness] 翻译台词仍需从原文匹配标签 / Match tags from source speech when translation is enabled

模板规则要求把标签写入 speech,而 translate 是独立的 TTS 目标语言文本。启用 LLM 翻译时,代码在匹配前已用 translate 覆盖 speech_text;合法输出如 speech: "[sad]..."、不含标签的 translate 因而永远不会触发情景语音。应从原始 speech 识别标签,再分别清理用于 TTS 的翻译文本。

Original in English

The template places tags in speech, but translation replaces speech_text before matching. A valid tagged source line with an untagged translation never activates preset or reference audio.

置信度 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))
Expand All @@ -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)

Expand Down
3 changes: 2 additions & 1 deletion core/handlers/ui_message_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,8 @@ def handle(self, out: TTSOutputMessage) -> None:
ui.hide_busy_bar()
ch = _play()
character_name = out.name
speech = out.text or ""
import re
speech = re.sub(r'\s*\[\w+\]\s*', '', out.text or "").strip()
sprite_id = out.asset_id
audio_path = out.audio_path
if audio_path:
Expand Down
2 changes: 2 additions & 0 deletions frontend/pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true
Loading