Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
93 changes: 93 additions & 0 deletions tests/unit/test_deprecated_yui_voice_migration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""废弃免费 YUI 预设音色的自动平移(PR: voice-tone-R6NtLH3Hk0 → 现役 yui_cn)。

回归点:YUI 默认音色 ID 更替后,存量用户 characters.json 里残留的旧 tone ID
已不在 free_voices 白名单,cleanup_invalid_voice_ids 会判 invalid 清空 → 空
voice 落到 free/step 的 default_voice(qingchunshaonv),导致默认 YUI 用户无声
掉档到通用女声。cleanup 在判 invalid 前先把旧值平移到现役 yui_cn 兜住。
"""
from __future__ import annotations

from utils.config_manager import ConfigManager, get_reserved


OLD_YUI_VOICE_ID = "voice-tone-R6NtLH3Hk0"
NEW_YUI_VOICE_ID = "voice-tone-RcH2svtsrw"


def _make_manager(character_data: dict) -> ConfigManager:
mgr = object.__new__(ConfigManager)
mgr._saved = {}
mgr.load_characters = lambda: character_data

def _save(data):
mgr._saved["data"] = data

mgr.save_characters = _save
return mgr


def _yui(voice_id: str) -> dict:
return {"猫娘": {"YUI": {"昵称": "YUI", "_reserved": {"voice_id": voice_id}}}}


def _patch_free_voices(monkeypatch, free_voices: dict):
monkeypatch.setattr("utils.api_config_loader.get_free_voices", lambda: free_voices)
monkeypatch.setattr("utils.language_utils.get_global_language_full", lambda: "zh-CN")


def test_cleanup_migrates_deprecated_yui_voice(monkeypatch):
_patch_free_voices(monkeypatch, {"yui_cn": NEW_YUI_VOICE_ID})
character_data = _yui(OLD_YUI_VOICE_ID)
mgr = _make_manager(character_data)

cleaned, legacy = mgr.cleanup_invalid_voice_ids()

assert cleaned == 0
assert legacy == []
assert get_reserved(character_data["猫娘"]["YUI"], "voice_id", default="") == NEW_YUI_VOICE_ID
# 迁移命中应触发存盘
assert mgr._saved.get("data") is character_data


def test_cleanup_keeps_current_yui_voice_untouched(monkeypatch):
_patch_free_voices(monkeypatch, {"yui_cn": NEW_YUI_VOICE_ID})
character_data = _yui(NEW_YUI_VOICE_ID)
mgr = _make_manager(character_data)
mgr.validate_voice_id = lambda voice_id: True # 现役 preset 合法

cleaned, legacy = mgr.cleanup_invalid_voice_ids()

assert cleaned == 0
assert get_reserved(character_data["猫娘"]["YUI"], "voice_id", default="") == NEW_YUI_VOICE_ID
# 无改动不应存盘
assert "data" not in mgr._saved


def test_cleanup_still_clears_unrelated_invalid_voice(monkeypatch):
"""回归:平移逻辑不能放过真正无效的、与 YUI 无关的存量 voice_id。"""
_patch_free_voices(monkeypatch, {"yui_cn": NEW_YUI_VOICE_ID})
character_data = {"猫娘": {"A": {"_reserved": {"voice_id": "some-stale-clone"}}}}
mgr = _make_manager(character_data)
mgr.validate_voice_id = lambda voice_id: False

cleaned, legacy = mgr.cleanup_invalid_voice_ids()

assert cleaned == 1
assert get_reserved(character_data["猫娘"]["A"], "voice_id", default="") == ""


def test_remap_keeps_deprecated_when_current_unresolvable(monkeypatch):
"""防御:现役 yui_cn 解析不出(或仍落在废弃集合)时不乱换,交给既有清空兜底。"""
_patch_free_voices(monkeypatch, {})
mgr = object.__new__(ConfigManager)

assert mgr.remap_deprecated_free_yui_voice_id(OLD_YUI_VOICE_ID) == OLD_YUI_VOICE_ID
assert mgr.remap_deprecated_free_yui_voice_id(NEW_YUI_VOICE_ID) == NEW_YUI_VOICE_ID
assert mgr.remap_deprecated_free_yui_voice_id("") == ""


def test_is_deprecated_free_yui_voice_id_predicate():
assert ConfigManager.is_deprecated_free_yui_voice_id(OLD_YUI_VOICE_ID) is True
assert ConfigManager.is_deprecated_free_yui_voice_id(f" {OLD_YUI_VOICE_ID} ") is True
assert ConfigManager.is_deprecated_free_yui_voice_id(NEW_YUI_VOICE_ID) is False
assert ConfigManager.is_deprecated_free_yui_voice_id("") is False
54 changes: 52 additions & 2 deletions utils/config_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,16 @@ def _is_default_yui_character(character_name: str, character_data: dict) -> bool
return _normalize_live2d_model_path(model_path) == DEFAULT_YUI_LIVE2D_MODEL_PATH


# 历史上 free_voices["yui_cn"] 用过、现已被替换的免费 YUI 预设音色 ID。
# 这些值仍残留在存量用户的 characters.json 里,但已不在 free_voices 白名单中,
# 会被 cleanup_invalid_voice_ids 判为 invalid 清空 → 空 voice 落到 free/step
# provider 的 default_voice(qingchunshaonv),导致「一直吃默认 YUI、从没手动
# 选过音色」的免费用户在音色 ID 更替后无声掉档到通用女声。cleanup 在判 invalid
# 前先把这些值平移到现役 yui_cn 即可兜住。将来再更替 YUI 音色时,把被替换掉的
# 旧值追加进这个集合。
_DEPRECATED_FREE_YUI_VOICE_IDS = frozenset({"voice-tone-R6NtLH3Hk0"})


def _get_default_yui_free_voice_id() -> str:
from utils.api_config_loader import get_free_voices
from utils.language_utils import get_global_language_full
Expand Down Expand Up @@ -2358,6 +2368,25 @@ def is_legacy_cosyvoice_id(voice_id: str) -> bool:
voice_id.startswith("cosyvoice-v2") or voice_id.startswith("cosyvoice-v3-")
)

@staticmethod
def is_deprecated_free_yui_voice_id(voice_id) -> bool:
"""voice_id 是否是已被替换、仍残留在存量存档里的免费 YUI 预设音色。"""
return bool(voice_id) and str(voice_id).strip() in _DEPRECATED_FREE_YUI_VOICE_IDS

def remap_deprecated_free_yui_voice_id(self, voice_id) -> str:
"""废弃的免费 YUI 预设音色 → 现役 yui_cn 值。

非废弃值、或现役 yui_cn 无法解析(仍落在废弃集合)时原样返回,让调用方
继续走既有的 validate / 清空兜底,绝不把废弃换成另一个废弃造成死循环。
"""
normalized = str(voice_id or '').strip()
if not self.is_deprecated_free_yui_voice_id(normalized):
return normalized
current = _get_default_yui_free_voice_id()
if current and current not in _DEPRECATED_FREE_YUI_VOICE_IDS:
return current
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Require yui_cn before remapping retired YUI IDs

In configurations where free_voices still contains other presets but no yui_cn entry, _get_default_yui_free_voice_id() falls back to cuteGirl or the first available preset. That makes this migration rewrite the retired YUI tone to an unrelated voice instead of leaving it for the existing validation/clear fallback, which is the documented behavior when the current YUI value cannot be resolved.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch,已采纳,33a14daa6 修复。remap 不再复用 _get_default_yui_free_voice_id(它带 cuteGirl/首个 preset 的宽松 fallback,那是给 ensure_default_yui_voice_for_free_api 的容错语义),改为直接读 free_voices["yui_cn"]——废弃值本就是 yui_cn 的旧版本,精确平移回它。yui_cn 缺失/为空/仍落在废弃集合时不迁移、交既有清空兜底,绝不借别的 preset 当替身把 YUI 串成别的音色。已加回归 test_remap_requires_yui_cn_not_other_preset

return normalized

def get_tts_api_key(self, provider: str) -> str | None:
"""根据 provider 统一获取 TTS API Key,返回 None 表示未配置。

Expand Down Expand Up @@ -2859,18 +2888,36 @@ def cleanup_invalid_voice_ids(self):
注意:免费预设音色在此处不会被清理(validate_voice_id 白名单放行),
实际可用性由 core.py 运行时按 free + lanlan.app/lanlan.tech 线路决定。

清空前还会先把已废弃的免费 YUI 预设音色平移到现役 yui_cn
(remap_deprecated_free_yui_voice_id),避免存量用户因 YUI 音色 ID 更替
被判 invalid 清空、无声掉档到通用默认音色。迁移命中同样触发存盘。

Returns:
(cleaned_count, legacy_cosyvoice_names): 清理总数 及 仍在使用旧版 CosyVoice 音色的角色名列表
"""
character_data = self.load_characters()
cleaned_count = 0
migrated_count = 0
legacy_cosyvoice_names: list[str] = []

catgirls = character_data.get('猫娘', {})
for name, config in catgirls.items():
voice_id = get_reserved(config, 'voice_id', default='', legacy_keys=('voice_id',))
if not voice_id:
continue
# 已废弃的免费 YUI 预设音色:先平移到现役 yui_cn,再 continue 跳过后续
# invalid 判定(新值在 free_voices 白名单内本就合法),保住默认 YUI 音色
remapped = self.remap_deprecated_free_yui_voice_id(voice_id)
if remapped and remapped != voice_id:
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid treating every stripped voice ID as a YUI migration

When a stored non-YUI voice_id has leading/trailing whitespace, remap_deprecated_free_yui_voice_id() returns the stripped value, so this condition treats it as a successful deprecated-YUI migration and continues before validate_voice_id() runs. For example, a stale value like some-stale-clone is saved as some-stale-clone and reported as migrated instead of being cleared during this cleanup pass, leaving an invalid voice active until a later cleanup.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

已采纳,33a14daa6 修复(与上面 CodeRabbit 同一处)。非废弃值现在原样返回原 voice_id、不再 strip,带空白的非废弃 voice_id 不会被当成 YUI 迁移而跳过清理。

set_reserved(config, 'voice_id', remapped)
migrated_count += 1
logger.info(
"猫娘 '%s' 的废弃 YUI 预设音色 '%s' 已平移到 '%s'",
name,
voice_id,
remapped,
)
continue
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# 旧版 CosyVoice 音色:保留 voice_id 不清空,仅记录供通知
if self.is_legacy_cosyvoice_id(voice_id):
legacy_cosyvoice_names.append(name)
Expand All @@ -2885,9 +2932,12 @@ def cleanup_invalid_voice_ids(self):
set_reserved(config, 'voice_id', '')
cleaned_count += 1

if cleaned_count > 0:
if cleaned_count > 0 or migrated_count > 0:
self.save_characters(character_data)
logger.info("已清理 %d 个无效的 voice_id 引用", cleaned_count)
if cleaned_count > 0:
logger.info("已清理 %d 个无效的 voice_id 引用", cleaned_count)
if migrated_count > 0:
logger.info("已平移 %d 个废弃 YUI 预设音色", migrated_count)

return cleaned_count, legacy_cosyvoice_names

Expand Down
Loading