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

回归点:YUI 默认音色 ID 更替后,存量用户 characters.json 里残留的旧 tone ID
已不在 free_voices 白名单,cleanup_invalid_voice_ids 会判 invalid 清空 → 空
voice 落到 free/step 的 default_voice(qingchunshaonv),导致默认 YUI 用户无声
掉档到通用女声。cleanup 在判 invalid 前先把旧值按线路平移:国内 free → 现役
yui_cn;海外 free → 品牌 sentinel "yui"(free_intl native),与
ensure_default_yui_voice_for_free_api 对偶。
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
"""
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"

_DOMESTIC_FREE = {"CORE_API_TYPE": "free", "CORE_URL": "wss://www.lanlan.tech/core"}
_OVERSEAS_FREE = {"CORE_API_TYPE": "free", "CORE_URL": "wss://www.lanlan.app/core"}
_NON_FREE = {"CORE_API_TYPE": "qwen", "CORE_URL": ""}


def _make_manager(character_data: dict, core_config: dict | None = None,
non_mainland: bool = False) -> ConfigManager:
mgr = object.__new__(ConfigManager)
mgr._saved = {}
mgr.load_characters = lambda: character_data

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

mgr.save_characters = _save
mgr.get_core_config = lambda: dict(core_config if core_config is not None else _DOMESTIC_FREE)
mgr._check_non_mainland = lambda: non_mainland
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_overseas_free_remaps_to_yui_sentinel(monkeypatch):
"""海外免费(lanlan.app)下废弃 StepFun tone 应绑品牌 sentinel "yui",
而非国内 voice-tone preset——否则非空 voice_id 会落进 external TTS。"""
_patch_free_voices(monkeypatch, {"yui_cn": NEW_YUI_VOICE_ID})
character_data = _yui(OLD_YUI_VOICE_ID)
mgr = _make_manager(character_data, core_config=_OVERSEAS_FREE)

cleaned, legacy = mgr.cleanup_invalid_voice_ids()

assert cleaned == 0
assert get_reserved(character_data["猫娘"]["YUI"], "voice_id", default="") == "yui"


def test_cleanup_overseas_by_geo_remaps_to_yui_sentinel(monkeypatch):
"""URL 仍是 lanlan.tech 但地理判海外时,靠 _check_non_mainland 兜底绑 "yui"。"""
_patch_free_voices(monkeypatch, {"yui_cn": NEW_YUI_VOICE_ID})
character_data = _yui(OLD_YUI_VOICE_ID)
mgr = _make_manager(character_data, core_config=_DOMESTIC_FREE, non_mainland=True)

mgr.cleanup_invalid_voice_ids()

assert get_reserved(character_data["猫娘"]["YUI"], "voice_id", default="") == "yui"


def test_remap_non_free_route_keeps_value(monkeypatch):
"""非 free 路由(如 qwen)下废弃 StepFun preset 用不上,不迁移、交清空兜底。"""
_patch_free_voices(monkeypatch, {"yui_cn": NEW_YUI_VOICE_ID})
mgr = _make_manager({}, core_config=_NON_FREE)

assert mgr.remap_deprecated_free_yui_voice_id(OLD_YUI_VOICE_ID) == OLD_YUI_VOICE_ID


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 但 free_voices 缺 yui_cn、只有别的 preset 时不得
借 cuteGirl 等当替身把废弃 YUI 串成别的音色——原样返回,交清空兜底。"""
_patch_free_voices(monkeypatch, {"cuteGirl": "voice-tone-PGLiyZt65w"})
mgr = _make_manager({})

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):
"""国内 free 但现役 yui_cn 解析不出(free_voices 为空)时不乱换。"""
_patch_free_voices(monkeypatch, {})
mgr = _make_manager({})

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
79 changes: 77 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,50 @@ 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 音色(按路由判定目标)。

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

废弃值是国内 StepFun YUI tone,只有 core=free 才该迁移,且目标按线路分流,
与 ensure_default_yui_voice_for_free_api 的 "yui"/yui_cn 二分对偶:
- 海外免费(lanlan.app → free_intl):voice-tone-* 不是该线路 native,
换成另一个国内 StepFun tone 反而让非空 voice_id 落进 external TTS;改绑
品牌 sentinel "yui"(free_intl 的 native/default),走海外原生语音路径。
- 国内免费(lanlan.tech):换成现役 free_voices["yui_cn"]。
非 free 路由、或现役 yui_cn 缺失/为空/仍落废弃集合时原样返回,交既有 validate
清空兜底——绝不借 cuteGirl 等其它 preset 当替身把 YUI 串成别的音色。
"""
if not self.is_deprecated_free_yui_voice_id(voice_id):
return voice_id
core_cfg = self.get_core_config() or {}
if (core_cfg.get("CORE_API_TYPE") or core_cfg.get("coreApi")) != "free":
return voice_id

# get_core_config() 已按非大陆把 CORE_URL 改写成 lanlan.app,URL 即可判海外;
# _check_non_mainland 兜底地理判定。与 ensure_default 同源。
core_url = str(core_cfg.get("CORE_URL") or "")
overseas = is_free_lanlan_app_route("free", core_url)
if not overseas:
try:
overseas = bool(self._check_non_mainland())
except Exception:
overseas = False
if overseas:
return "yui"
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 Remap persisted yui when returning domestic

For users whose deprecated YUI tone is first cleaned on the overseas lanlan.app route, this persists the route-specific sentinel "yui" and skips validation. If the same character later runs on the domestic lanlan.tech free route, validate_voice_id() does not accept "yui" there (the effective native provider is free, and free_voices contains the StepFun yui_cn tone instead), so the next cleanup clears the voice and the user falls back to qingchunshaonv; the original deprecated ID has already been overwritten, so it can no longer be mapped to yui_cn. Consider also migrating saved "yui" back to the current yui_cn on the domestic free route, or avoid persisting a route-specific sentinel as the only migration state.

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.

已采纳你的第二个建议(不持久化 route-specific sentinel),78a618b7a 修复。海外 free 不再 remap 成 "yui",而是原样返回交既有 validate 清空成空串(而非 "yui")。空 voice 切回国内时由 update_core_config → ensure_default_yui_voice_for_free_api 重绑现役 yui_cn(与默认 YUI 首次绑定同源),不会停在 qingchunshaonv。关键区别:空值能触发 ensure_default 重绑,而 af8e26e 存的非空 "yui" 会让 ensure_default 因 voice 非空跳过、再被 cleanup 清空,正是你指出的掉档路径。


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 +2913,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 +2957,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