-
Notifications
You must be signed in to change notification settings - Fork 159
fix(voice): 自动平移废弃的免费 YUI 预设音色,避免存量用户掉档到 qingchunshaonv #1665
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
94fa15a
33a14da
a804766
af8e26e
78a618b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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() | ||
| 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 表示未配置。 | ||
|
|
||
|
|
@@ -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: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a stored non-YUI Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 已采纳,33a14daa6 修复(与上面 CodeRabbit 同一处)。非废弃值现在原样返回原 |
||
| set_reserved(config, 'voice_id', remapped) | ||
| migrated_count += 1 | ||
| logger.info( | ||
| "猫娘 '%s' 的废弃 YUI 预设音色 '%s' 已平移到 '%s'", | ||
| name, | ||
| voice_id, | ||
| remapped, | ||
| ) | ||
| continue | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| # 旧版 CosyVoice 音色:保留 voice_id 不清空,仅记录供通知 | ||
| if self.is_legacy_cosyvoice_id(voice_id): | ||
| legacy_cosyvoice_names.append(name) | ||
|
|
@@ -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 | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a free user is on the overseas
lanlan.approute and still has this deprecated StepFun YUI tone incharacters.json, remapping it unconditionally tofree_voices["yui_cn"]preserves a domestic free preset where the overseas path expects the literalyui/Gemini catalog instead. I checkedmain_logic/core.py:_resolve_session_use_tts()only treats free presets as native onlanlan.tech, and otherwise a nonemptyself.voice_idfalls into external TTS, whilenative_voice_registryremapsfree+lanlan.apptofree_intlwhereyui_cnis 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 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Confirmed,好 catch,af8e26e14 修复。你说的对:海外
lanlan.app→free_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 不迁移三条回归。There was a problem hiding this comment.
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」一致。