Skip to content

Commit de8f2ec

Browse files
wehosHongzhi Wenclaude
authored
fix(voice): 自动平移废弃的免费 YUI 预设音色,避免存量用户掉档到 qingchunshaonv (#1665)
* fix(voice): 自动平移废弃的免费 YUI 预设音色,避免存量用户掉档到 qingchunshaonv #1639 把 free_voices["yui_cn"] 从 voice-tone-R6NtLH3Hk0 换成 voice-tone-RcH2svtsrw, 但只改了 config 模板和解析源,没迁移已写进存量 characters.json 的旧 tone ID。旧值已 不在 free_voices 白名单 → cleanup_invalid_voice_ids 判 invalid 清空 → 空 voice 落到 free/step 的 default_voice(qingchunshaonv),导致「一直吃默认 YUI、从没手动选过音色」 的免费用户在音色 ID 更替后无声掉档到通用女声。 在 cleanup 判 invalid 前先把废弃 YUI tone 平移到现役 yui_cn: - 新增 _DEPRECATED_FREE_YUI_VOICE_IDS 集合 + is_deprecated_free_yui_voice_id / remap_deprecated_free_yui_voice_id(对偶既有 is_legacy_cosyvoice_id) - 现役值解析不出(或仍落废弃集合)时不乱换,交既有清空兜底,绝不废弃换废弃 - 迁移命中触发存盘;覆盖 app 初始化与每次 start_session 两条 cleanup 路径 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(voice): remap 只认 yui_cn 且非废弃值原样返回,堵两个 reviewer 指出的漏洞 CodeRabbit/Codex: remap 对非废弃值做了 strip 归一化,带前后空白的无效 voice_id 会 因 remapped != voice_id 被误当「已迁移」continue,漏掉本轮 invalid 清理 → 改为非废弃 值原样返回原值(strip 收口在 _get_voice_id 源头,不是 remap 的职责)。 Codex: 复用 _get_default_yui_free_voice_id 会在 free_voices 缺 yui_cn 时 fallback 到 cuteGirl/首个 preset,把废弃 YUI 串成别的音色 → 改为直接读 free_voices["yui_cn"], 缺失/为空/仍落废弃集合时不迁移,交既有清空兜底。 新增「带空白废弃仍迁移」「带空白非废弃 invalid 仍清空」「缺 yui_cn 不借替身」三条回归。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(voice): 给带空白废弃值迁移用例补存盘断言(CodeRabbit nitpick) 与 test_cleanup_migrates_deprecated_yui_voice 对偶,验证带前后空白的废弃值迁移 路径同样触发 save_characters,而非只改了内存对象。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(voice): 废弃 YUI remap 按线路分流,海外 free 绑 yui sentinel(Codex P2) Codex: 海外 free(lanlan.app→free_intl)用户若残留国内 StepFun YUI tone,无条件 remap 成 voice-tone yui_cn 仍是国内 preset;海外不认 voice-tone-*,非空 voice_id 会 从海外 native/default(yui) 路径 regress 到 external TTS(_resolve_session_use_tts 仅 在 lanlan.tech 把 free preset 当 native)。改前 stale id 本会被 validation 清空。 remap 改为对偶 ensure_default_yui_voice_for_free_api 的线路二分: - 仅 core=free 才迁移;非 free 路由原样返回交清空兜底 - 海外 free(lanlan.app / _check_non_mainland 兜底)→ 品牌 sentinel "yui" - 国内 free(lanlan.tech)→ 现役 free_voices["yui_cn"] 新增「海外绑 yui」「URL 仍 tech 但地理判海外兜底」「非 free 不迁移」三条用例。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(voice): 海外 free 废弃 YUI 不注入 yui,交服务端默认(遵 PR #1643 契约) af8e26e 让海外 free 把废弃 StepFun YUI tone remap 成 "yui" sentinel,违反 PR #1643 确立的设计原则:海外 free(lanlan.app→free_intl)下未知/stale voice id 应交服务端默认 音色 fallback,客户端不得注入 yui 或 native alias(free_intl 继承 Gemini-native provider,不能把 StepFun magic id/alias 漏进该 catalog)。 remap 改为只有国内 free(lanlan.tech)才迁移到现役 yui_cn;海外 free 与非 free 路由均 原样返回,交既有 validate 判 invalid 清空 → 落服务端默认。两种线路运行时都得到正确 YUI 音色,且 cross-region 往返更稳(切回国内由 ensure_default 兜底重绑)。 测试相应改为海外不 remap(原样返回 / cleanup 清空),删掉 "yui" sentinel 断言。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Hongzhi Wen <cartabio.coder1@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1c9e0d6 commit de8f2ec

2 files changed

Lines changed: 256 additions & 2 deletions

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
"""废弃免费 YUI 预设音色的自动平移(PR: voice-tone-R6NtLH3Hk0 → 现役国内 yui_cn)。
2+
3+
回归点:YUI 默认音色 ID 更替后,存量用户 characters.json 里残留的旧 tone ID
4+
已不在 free_voices 白名单,cleanup_invalid_voice_ids 会判 invalid 清空 → 空
5+
voice 落到 free/step 的 default_voice(qingchunshaonv),导致默认 YUI 用户无声
6+
掉档到通用女声。cleanup 在判 invalid 前先迁移:仅国内 free(lanlan.tech)平移到
7+
现役 yui_cn;海外 free(lanlan.app→free_intl)与非 free 路由不迁移,原样交既有
8+
validate 清空 → 落服务端默认(free_intl 的 "未知 ID 交服务端默认" 契约,PR #1643:
9+
客户端不得把 StepFun magic id 或 alias 注入 free_intl catalog)。
10+
"""
11+
from __future__ import annotations
12+
13+
from utils.config_manager import ConfigManager, get_reserved
14+
15+
16+
OLD_YUI_VOICE_ID = "voice-tone-R6NtLH3Hk0"
17+
NEW_YUI_VOICE_ID = "voice-tone-RcH2svtsrw"
18+
19+
_DOMESTIC_FREE = {"CORE_API_TYPE": "free", "CORE_URL": "wss://www.lanlan.tech/core"}
20+
_OVERSEAS_FREE = {"CORE_API_TYPE": "free", "CORE_URL": "wss://www.lanlan.app/core"}
21+
_NON_FREE = {"CORE_API_TYPE": "qwen", "CORE_URL": ""}
22+
23+
24+
def _make_manager(character_data: dict, core_config: dict | None = None,
25+
non_mainland: bool = False) -> ConfigManager:
26+
mgr = object.__new__(ConfigManager)
27+
mgr._saved = {}
28+
mgr.load_characters = lambda: character_data
29+
30+
def _save(data):
31+
mgr._saved["data"] = data
32+
33+
mgr.save_characters = _save
34+
mgr.get_core_config = lambda: dict(core_config if core_config is not None else _DOMESTIC_FREE)
35+
mgr._check_non_mainland = lambda: non_mainland
36+
return mgr
37+
38+
39+
def _yui(voice_id: str) -> dict:
40+
return {"猫娘": {"YUI": {"昵称": "YUI", "_reserved": {"voice_id": voice_id}}}}
41+
42+
43+
def _patch_free_voices(monkeypatch, free_voices: dict):
44+
monkeypatch.setattr("utils.api_config_loader.get_free_voices", lambda: free_voices)
45+
46+
47+
def test_cleanup_migrates_deprecated_yui_voice(monkeypatch):
48+
_patch_free_voices(monkeypatch, {"yui_cn": NEW_YUI_VOICE_ID})
49+
character_data = _yui(OLD_YUI_VOICE_ID)
50+
mgr = _make_manager(character_data)
51+
52+
cleaned, legacy = mgr.cleanup_invalid_voice_ids()
53+
54+
assert cleaned == 0
55+
assert legacy == []
56+
assert get_reserved(character_data["猫娘"]["YUI"], "voice_id", default="") == NEW_YUI_VOICE_ID
57+
# 迁移命中应触发存盘
58+
assert mgr._saved.get("data") is character_data
59+
60+
61+
def test_cleanup_migrates_whitespace_padded_deprecated_voice(monkeypatch):
62+
"""带前后空白的废弃值也应被识别并平移到干净的现役 yui_cn。"""
63+
_patch_free_voices(monkeypatch, {"yui_cn": NEW_YUI_VOICE_ID})
64+
character_data = _yui(f" {OLD_YUI_VOICE_ID} ")
65+
mgr = _make_manager(character_data)
66+
67+
cleaned, legacy = mgr.cleanup_invalid_voice_ids()
68+
69+
assert cleaned == 0
70+
assert get_reserved(character_data["猫娘"]["YUI"], "voice_id", default="") == NEW_YUI_VOICE_ID
71+
# 迁移命中应触发存盘(与 test_cleanup_migrates_deprecated_yui_voice 对偶)
72+
assert mgr._saved.get("data") is character_data
73+
74+
75+
def test_remap_overseas_free_keeps_value_for_server_default(monkeypatch):
76+
"""海外 free(lanlan.app→free_intl):废弃 StepFun tone 不迁移,原样返回,交既有
77+
validate 清空 → 落服务端默认。客户端绝不注入 "yui"/native alias(PR #1643)。"""
78+
_patch_free_voices(monkeypatch, {"yui_cn": NEW_YUI_VOICE_ID})
79+
mgr = _make_manager({}, core_config=_OVERSEAS_FREE)
80+
81+
assert mgr.remap_deprecated_free_yui_voice_id(OLD_YUI_VOICE_ID) == OLD_YUI_VOICE_ID
82+
83+
84+
def test_remap_overseas_by_geo_keeps_value(monkeypatch):
85+
"""URL 仍是 lanlan.tech 但地理判海外时,靠 _check_non_mainland 兜底也不迁移。"""
86+
_patch_free_voices(monkeypatch, {"yui_cn": NEW_YUI_VOICE_ID})
87+
mgr = _make_manager({}, core_config=_DOMESTIC_FREE, non_mainland=True)
88+
89+
assert mgr.remap_deprecated_free_yui_voice_id(OLD_YUI_VOICE_ID) == OLD_YUI_VOICE_ID
90+
91+
92+
def test_cleanup_overseas_clears_stale_yui_for_server_default(monkeypatch):
93+
"""端到端:海外 free 下废弃 tone 不被迁移、由 validate 判 invalid 清空 → 落服务端默认。"""
94+
_patch_free_voices(monkeypatch, {"yui_cn": NEW_YUI_VOICE_ID})
95+
character_data = _yui(OLD_YUI_VOICE_ID)
96+
mgr = _make_manager(character_data, core_config=_OVERSEAS_FREE)
97+
mgr.validate_voice_id = lambda voice_id: False # 海外线路下 stale StepFun tone invalid
98+
99+
cleaned, legacy = mgr.cleanup_invalid_voice_ids()
100+
101+
assert cleaned == 1
102+
assert get_reserved(character_data["猫娘"]["YUI"], "voice_id", default="") == ""
103+
104+
105+
def test_remap_non_free_route_keeps_value(monkeypatch):
106+
"""非 free 路由(如 qwen)下废弃 StepFun preset 用不上,不迁移、交清空兜底。"""
107+
_patch_free_voices(monkeypatch, {"yui_cn": NEW_YUI_VOICE_ID})
108+
mgr = _make_manager({}, core_config=_NON_FREE)
109+
110+
assert mgr.remap_deprecated_free_yui_voice_id(OLD_YUI_VOICE_ID) == OLD_YUI_VOICE_ID
111+
112+
113+
def test_cleanup_keeps_current_yui_voice_untouched(monkeypatch):
114+
_patch_free_voices(monkeypatch, {"yui_cn": NEW_YUI_VOICE_ID})
115+
character_data = _yui(NEW_YUI_VOICE_ID)
116+
mgr = _make_manager(character_data)
117+
mgr.validate_voice_id = lambda voice_id: True # 现役 preset 合法
118+
119+
cleaned, legacy = mgr.cleanup_invalid_voice_ids()
120+
121+
assert cleaned == 0
122+
assert get_reserved(character_data["猫娘"]["YUI"], "voice_id", default="") == NEW_YUI_VOICE_ID
123+
# 无改动不应存盘
124+
assert "data" not in mgr._saved
125+
126+
127+
def test_cleanup_still_clears_unrelated_invalid_voice(monkeypatch):
128+
"""回归:平移逻辑不能放过真正无效的、与 YUI 无关的存量 voice_id。"""
129+
_patch_free_voices(monkeypatch, {"yui_cn": NEW_YUI_VOICE_ID})
130+
character_data = {"猫娘": {"A": {"_reserved": {"voice_id": "some-stale-clone"}}}}
131+
mgr = _make_manager(character_data)
132+
mgr.validate_voice_id = lambda voice_id: False
133+
134+
cleaned, legacy = mgr.cleanup_invalid_voice_ids()
135+
136+
assert cleaned == 1
137+
assert get_reserved(character_data["猫娘"]["A"], "voice_id", default="") == ""
138+
139+
140+
def test_cleanup_clears_whitespace_padded_invalid_voice(monkeypatch):
141+
"""回归(CodeRabbit / Codex):带前后空白的非废弃无效 voice_id 不能被 remap
142+
的归一化误当成「已迁移」而漏清,必须照常走 invalid 清空。"""
143+
_patch_free_voices(monkeypatch, {"yui_cn": NEW_YUI_VOICE_ID})
144+
character_data = {"猫娘": {"A": {"_reserved": {"voice_id": " some-stale-clone "}}}}
145+
mgr = _make_manager(character_data)
146+
mgr.validate_voice_id = lambda voice_id: False
147+
148+
cleaned, legacy = mgr.cleanup_invalid_voice_ids()
149+
150+
assert cleaned == 1
151+
assert get_reserved(character_data["猫娘"]["A"], "voice_id", default="") == ""
152+
153+
154+
def test_remap_requires_yui_cn_not_other_preset(monkeypatch):
155+
"""回归(Codex):国内 free 但 free_voices 缺 yui_cn、只有别的 preset 时不得
156+
借 cuteGirl 等当替身把废弃 YUI 串成别的音色——原样返回,交清空兜底。"""
157+
_patch_free_voices(monkeypatch, {"cuteGirl": "voice-tone-PGLiyZt65w"})
158+
mgr = _make_manager({})
159+
160+
assert mgr.remap_deprecated_free_yui_voice_id(OLD_YUI_VOICE_ID) == OLD_YUI_VOICE_ID
161+
162+
163+
def test_remap_keeps_deprecated_when_current_unresolvable(monkeypatch):
164+
"""国内 free 但现役 yui_cn 解析不出(free_voices 为空)时不乱换。"""
165+
_patch_free_voices(monkeypatch, {})
166+
mgr = _make_manager({})
167+
168+
assert mgr.remap_deprecated_free_yui_voice_id(OLD_YUI_VOICE_ID) == OLD_YUI_VOICE_ID
169+
assert mgr.remap_deprecated_free_yui_voice_id(NEW_YUI_VOICE_ID) == NEW_YUI_VOICE_ID
170+
assert mgr.remap_deprecated_free_yui_voice_id("") == ""
171+
172+
173+
def test_is_deprecated_free_yui_voice_id_predicate():
174+
assert ConfigManager.is_deprecated_free_yui_voice_id(OLD_YUI_VOICE_ID) is True
175+
assert ConfigManager.is_deprecated_free_yui_voice_id(f" {OLD_YUI_VOICE_ID} ") is True
176+
assert ConfigManager.is_deprecated_free_yui_voice_id(NEW_YUI_VOICE_ID) is False
177+
assert ConfigManager.is_deprecated_free_yui_voice_id("") is False

utils/config_manager.py

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,16 @@ def _is_default_yui_character(character_name: str, character_data: dict) -> bool
157157
return _normalize_live2d_model_path(model_path) == DEFAULT_YUI_LIVE2D_MODEL_PATH
158158

159159

160+
# 历史上 free_voices["yui_cn"] 用过、现已被替换的免费 YUI 预设音色 ID。
161+
# 这些值仍残留在存量用户的 characters.json 里,但已不在 free_voices 白名单中,
162+
# 会被 cleanup_invalid_voice_ids 判为 invalid 清空 → 空 voice 落到 free/step
163+
# provider 的 default_voice(qingchunshaonv),导致「一直吃默认 YUI、从没手动
164+
# 选过音色」的免费用户在音色 ID 更替后无声掉档到通用女声。cleanup 在判 invalid
165+
# 前先把这些值平移到现役 yui_cn 即可兜住。将来再更替 YUI 音色时,把被替换掉的
166+
# 旧值追加进这个集合。
167+
_DEPRECATED_FREE_YUI_VOICE_IDS = frozenset({"voice-tone-R6NtLH3Hk0"})
168+
169+
160170
def _get_default_yui_free_voice_id() -> str:
161171
from utils.api_config_loader import get_free_voices
162172
from utils.language_utils import get_global_language_full
@@ -2358,6 +2368,52 @@ def is_legacy_cosyvoice_id(voice_id: str) -> bool:
23582368
voice_id.startswith("cosyvoice-v2") or voice_id.startswith("cosyvoice-v3-")
23592369
)
23602370

2371+
@staticmethod
2372+
def is_deprecated_free_yui_voice_id(voice_id) -> bool:
2373+
"""voice_id 是否是已被替换、仍残留在存量存档里的免费 YUI 预设音色。"""
2374+
return bool(voice_id) and str(voice_id).strip() in _DEPRECATED_FREE_YUI_VOICE_IDS
2375+
2376+
def remap_deprecated_free_yui_voice_id(self, voice_id):
2377+
"""废弃的免费 YUI 预设音色 → 现役国内 yui_cn(仅国内 free 线路迁移)。
2378+
2379+
非废弃值原样返回(不做 strip 归一化),避免调用方把单纯的前后空白差异
2380+
误当成「已迁移」而 continue,漏掉本轮对无效 voice_id 的清理。
2381+
2382+
废弃值是国内 StepFun YUI tone,只有国内免费(lanlan.tech)线路会真正下发它,
2383+
也只有该线路迁移到现役 free_voices["yui_cn"]:
2384+
- 海外免费(lanlan.app → free_intl):原样返回,交既有 validate 在海外线路
2385+
判 invalid 清空 → 落服务端默认音色 fallback。客户端不注入 "yui"/native
2386+
alias(PR #1643 设计原则:free_intl 继承 Gemini-native provider,不得把
2387+
StepFun magic id 或其 alias 漏进该 catalog;且无条件换成国内 voice-tone
2388+
还会让非空 voice_id 在 free_intl 落进 external TTS)。
2389+
- 非 free 路由:原样返回,废弃 StepFun preset 用不上,交清空兜底。
2390+
现役 yui_cn 缺失/为空/仍落废弃集合时也原样返回——绝不借 cuteGirl 等其它 preset
2391+
当替身把 YUI 串成别的音色,也不把废弃换成另一个废弃造成死循环。
2392+
"""
2393+
if not self.is_deprecated_free_yui_voice_id(voice_id):
2394+
return voice_id
2395+
core_cfg = self.get_core_config() or {}
2396+
if (core_cfg.get("CORE_API_TYPE") or core_cfg.get("coreApi")) != "free":
2397+
return voice_id
2398+
2399+
# get_core_config() 已按非大陆把 CORE_URL 改写成 lanlan.app,URL 即可判海外;
2400+
# _check_non_mainland 兜底地理判定。与 ensure_default 同源。海外不迁移(见上)。
2401+
core_url = str(core_cfg.get("CORE_URL") or "")
2402+
overseas = is_free_lanlan_app_route("free", core_url)
2403+
if not overseas:
2404+
try:
2405+
overseas = bool(self._check_non_mainland())
2406+
except Exception:
2407+
overseas = False
2408+
if overseas:
2409+
return voice_id
2410+
2411+
from utils.api_config_loader import get_free_voices
2412+
current = str((get_free_voices() or {}).get("yui_cn") or "").strip()
2413+
if current and current not in _DEPRECATED_FREE_YUI_VOICE_IDS:
2414+
return current
2415+
return voice_id
2416+
23612417
def get_tts_api_key(self, provider: str) -> str | None:
23622418
"""根据 provider 统一获取 TTS API Key,返回 None 表示未配置。
23632419
@@ -2859,18 +2915,36 @@ def cleanup_invalid_voice_ids(self):
28592915
注意:免费预设音色在此处不会被清理(validate_voice_id 白名单放行),
28602916
实际可用性由 core.py 运行时按 free + lanlan.app/lanlan.tech 线路决定。
28612917
2918+
清空前还会先把已废弃的免费 YUI 预设音色平移到现役 yui_cn
2919+
(remap_deprecated_free_yui_voice_id),避免存量用户因 YUI 音色 ID 更替
2920+
被判 invalid 清空、无声掉档到通用默认音色。迁移命中同样触发存盘。
2921+
28622922
Returns:
28632923
(cleaned_count, legacy_cosyvoice_names): 清理总数 及 仍在使用旧版 CosyVoice 音色的角色名列表
28642924
"""
28652925
character_data = self.load_characters()
28662926
cleaned_count = 0
2927+
migrated_count = 0
28672928
legacy_cosyvoice_names: list[str] = []
28682929

28692930
catgirls = character_data.get('猫娘', {})
28702931
for name, config in catgirls.items():
28712932
voice_id = get_reserved(config, 'voice_id', default='', legacy_keys=('voice_id',))
28722933
if not voice_id:
28732934
continue
2935+
# 已废弃的免费 YUI 预设音色:先平移到现役 yui_cn,再 continue 跳过后续
2936+
# invalid 判定(新值在 free_voices 白名单内本就合法),保住默认 YUI 音色
2937+
remapped = self.remap_deprecated_free_yui_voice_id(voice_id)
2938+
if remapped and remapped != voice_id:
2939+
set_reserved(config, 'voice_id', remapped)
2940+
migrated_count += 1
2941+
logger.info(
2942+
"猫娘 '%s' 的废弃 YUI 预设音色 '%s' 已平移到 '%s'",
2943+
name,
2944+
voice_id,
2945+
remapped,
2946+
)
2947+
continue
28742948
# 旧版 CosyVoice 音色:保留 voice_id 不清空,仅记录供通知
28752949
if self.is_legacy_cosyvoice_id(voice_id):
28762950
legacy_cosyvoice_names.append(name)
@@ -2885,9 +2959,12 @@ def cleanup_invalid_voice_ids(self):
28852959
set_reserved(config, 'voice_id', '')
28862960
cleaned_count += 1
28872961

2888-
if cleaned_count > 0:
2962+
if cleaned_count > 0 or migrated_count > 0:
28892963
self.save_characters(character_data)
2890-
logger.info("已清理 %d 个无效的 voice_id 引用", cleaned_count)
2964+
if cleaned_count > 0:
2965+
logger.info("已清理 %d 个无效的 voice_id 引用", cleaned_count)
2966+
if migrated_count > 0:
2967+
logger.info("已平移 %d 个废弃 YUI 预设音色", migrated_count)
28912968

28922969
return cleaned_count, legacy_cosyvoice_names
28932970

0 commit comments

Comments
 (0)