Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
129 changes: 129 additions & 0 deletions tests/unit/test_deprecated_yui_voice_migration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""废弃免费 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)


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_migrates_whitespace_padded_deprecated_voice(monkeypatch):
"""带前后空白的废弃值也应被识别并平移到干净的现役 yui_cn。"""
_patch_free_voices(monkeypatch, {"yui_cn": NEW_YUI_VOICE_ID})
character_data = _yui(f" {OLD_YUI_VOICE_ID} ")
mgr = _make_manager(character_data)

cleaned, legacy = mgr.cleanup_invalid_voice_ids()

assert cleaned == 0
assert get_reserved(character_data["猫娘"]["YUI"], "voice_id", default="") == NEW_YUI_VOICE_ID
# 迁移命中应触发存盘(与 test_cleanup_migrates_deprecated_yui_voice 对偶)
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_cleanup_clears_whitespace_padded_invalid_voice(monkeypatch):
"""回归(CodeRabbit / Codex):带前后空白的非废弃无效 voice_id 不能被 remap
的归一化误当成「已迁移」而漏清,必须照常走 invalid 清空。"""
_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_requires_yui_cn_not_other_preset(monkeypatch):
"""回归(Codex):free_voices 缺 yui_cn、只有别的 preset 时不得借 cuteGirl
等当替身把废弃 YUI 串成别的音色——原样返回,交清空兜底。"""
_patch_free_voices(monkeypatch, {"cuteGirl": "voice-tone-PGLiyZt65w"})
mgr = object.__new__(ConfigManager)

assert mgr.remap_deprecated_free_yui_voice_id(OLD_YUI_VOICE_ID) == OLD_YUI_VOICE_ID


def test_remap_keeps_deprecated_when_current_unresolvable(monkeypatch):
"""现役 yui_cn 解析不出(free_voices 为空)时不乱换。"""
_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
59 changes: 57 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,30 @@ 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):
"""废弃的免费 YUI 预设音色 → 现役 yui_cn 值。

非废弃值原样返回(不做 strip 归一化),避免调用方把单纯的前后空白差异
误当成「已迁移」而 continue,漏掉本轮对无效 voice_id 的清理。

目标只认 free_voices 的 yui_cn(废弃值本就是它的旧版本);yui_cn 缺失、
为空、或仍落在废弃集合时同样原样返回,交既有 validate / 清空兜底——绝不
借用 cuteGirl 等其它 preset 当替身,那会把 YUI 串成别的音色,也不会把废弃
换成另一个废弃造成死循环。
"""
if not self.is_deprecated_free_yui_voice_id(voice_id):
return voice_id
from utils.api_config_loader import get_free_voices
current = str((get_free_voices() or {}).get("yui_cn") or "").strip()
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 Gate deprecated YUI remaps by route

When a free user is on the overseas lanlan.app route and still has this deprecated StepFun YUI tone in characters.json, remapping it unconditionally to free_voices["yui_cn"] preserves a domestic free preset where the overseas path expects the literal yui/Gemini catalog instead. I checked main_logic/core.py: _resolve_session_use_tts() only treats free presets as native on lanlan.tech, and otherwise a nonempty self.voice_id falls into external TTS, while native_voice_registry remaps free+lanlan.app to free_intl where yui_cn is not a native voice. Before this change the stale ID was cleared by validation, so this can regress those users from the intended overseas native/default voice path to an external TTS path.

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.

Confirmed,好 catch,af8e26e14 修复。你说的对:海外 lanlan.appfree_intl 不认 voice-tone-*,无条件 remap 成国内 yui_cn 会让非空 voice_id 在 _resolve_session_use_tts(free preset 只在 lanlan.tech 当 native)落进 external TTS,对从国内切到海外的存量用户是 regression。

已把 remap 改成按线路分流,对偶 ensure_default_yui_voice_for_free_api"yui"/yui_cn 二分:仅 core=free 才迁移(非 free 路由原样返回交清空兜底);海外 free(is_free_lanlan_app_route + _check_non_mainland 兜底)→ 品牌 sentinel "yui"(free_intl native,走海外原生语音);国内 free(lanlan.tech)→ 现役 free_voices["yui_cn"]。新增海外绑 yui / 地理兜底 / 非 free 不迁移三条回归。

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.

更正一下我上一条回复的方向:78a618b7a 起,海外 free 不再 remap 成 "yui"。经核对 PR #1643 的既有契约——海外 free(lanlan.app→free_intl)下 stale/未知 voice id 应交服务端默认音色 fallback,客户端不得注入 yui/native alias(free_intl 继承 Gemini-native provider,StepFun magic id 及其 alias 都不该漏进该 catalog)。所以现在只有国内 free(lanlan.tech)迁移到 yui_cn;海外 free 原样返回交既有 validate 清空 → 落服务端默认,与你最初指出的「按路由 gate、别保留国内 preset」一致。

if current and current not in _DEPRECATED_FREE_YUI_VOICE_IDS:
return current
return voice_id

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

Expand Down Expand Up @@ -2859,18 +2893,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 +2937,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