Skip to content

Commit d0486ae

Browse files
LyaQanYiclaude
andcommitted
fix(gemini-tts): GSV 启用时仍走 Gemini 原生 + 别名/未知 voice_id 保底 + free_voices 去重
回应三轮 review: 1. codex P2 (core.py:2314) — 当 core=gemini + 选了 Gemini 原生声线 + 用户启用了 GPT-SoVITS / local CosyVoice 时,_has_custom_tts 短路 False 不够: get_tts_worker 会先命中 tts_custom is_custom + gsv_enabled 分支返回 gptsovits_tts_worker / local_cosyvoice_worker,绕过 Gemini。修法是在 get_tts_worker 的 GSV 检查之前加一个 has_custom_voice=False + core=gemini + is_gemini_tts_voice(voice_id) 短路,直接返回 gemini_tts_worker。 2. coderabbit Major (character_card_manager.js:5523) — Gemini 原生音色下拉只在 voiceId === currentVoiceId 时 select,没有 GSV 那种 ensureFallback 兜底; 一旦角色保存的是别名(如"中文男")或本轮 catalog 没暴露该 ID,select 会 回到首项,下次保存表单走 unregister_voice 把用户音色丢掉。仿 _loadPanelGsvVoices 的兜底,给未知值补一条 "(?)" 占位 option 保留原值。 配套加 character.savedVoiceFallback i18n key(8 个 locale),无 key 时回退 到中文字面量。 3. coderabbit Minor (voice_clone.js:1264) — 原生音色去重只看自定义注册音色, 没扣 free_voices;冲突时同 ID 会被渲两遍,多重 selected 视觉态。把 free_voices 的 voiceId 一并加入去重 set。同样的扩展也应用到 character_card_manager.js 的 optgroup 渲染分支。 测试:tests/unit/test_gemini_tts_voices.py 29 用例通过;node --check 两份 JS、 json.load 8 份 locale 全部通过。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent aeacafa commit d0486ae

11 files changed

Lines changed: 58 additions & 8 deletions

File tree

main_logic/tts_client.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from utils.config_manager import get_config_manager
2121
from utils.gemini_tts_voices import (
2222
GEMINI_TTS_MODEL,
23+
is_gemini_tts_voice,
2324
normalize_gemini_tts_voice,
2425
)
2526
from utils.logger_config import get_module_logger
@@ -2887,6 +2888,17 @@ def get_tts_worker(core_api_type='qwen', has_custom_voice=False, voice_id=''):
28872888
worker = partial(minimax_tts_worker, base_url=base_url)
28882889
return worker, api_key, 'minimax'
28892890

2891+
# core=gemini + 选了 Gemini 原生声线 (Puck/Leda/中文男 等) 时优先走 Gemini,
2892+
# 不能被 has_custom_voice=False 的 GPT-SoVITS / local CosyVoice fallthrough 拦截 ——
2893+
# _has_custom_tts 已经判断 voice_id 不是用户克隆音色,这里 has_custom_voice 必为 False,
2894+
# 是用户显式选择的 Gemini 原生路径,应当尊重该选择喵。
2895+
if (
2896+
not has_custom_voice
2897+
and core_api_type == 'gemini'
2898+
and is_gemini_tts_voice(voice_id)
2899+
):
2900+
return gemini_tts_worker, None, 'gemini'
2901+
28902902
try:
28912903
tts_config = cm.get_model_api_config('tts_custom')
28922904
if tts_config.get('is_custom'):

static/js/character_card_manager.js

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5501,12 +5501,18 @@ async function _loadPanelVoices(selectEl, currentVoiceId) {
55015501
}
55025502

55035503
// Gemini 原生音色(仅在 CORE_API_TYPE=gemini 时由后端注入)
5504-
// 与已注册自定义音色冲突时(如用户克隆了名为 Puck 的音色),优先保留自定义条目,
5505-
// 与 _has_custom_tts 路由优先级保持一致。
5504+
// 去重范围:已注册自定义音色 + 已渲染的免费预设音色 ID,
5505+
// 避免任一冲突时下拉里重复条目和多重 selected 视觉态。
5506+
// 自定义/免费音色优先保留,与 _has_custom_tts 的路由优先级一致。
55065507
if (data.native_voices && Object.keys(data.native_voices).length > 0) {
5507-
const registeredVoiceIds = data.voices ? new Set(Object.keys(data.voices)) : new Set();
5508+
const renderedVoiceIds = new Set(data.voices ? Object.keys(data.voices) : []);
5509+
if (data.free_voices) {
5510+
Object.values(data.free_voices).forEach(function (id) {
5511+
if (id) renderedVoiceIds.add(String(id));
5512+
});
5513+
}
55085514
const nativeEntries = Object.entries(data.native_voices)
5509-
.filter(function ([voiceId]) { return !registeredVoiceIds.has(voiceId); });
5515+
.filter(function ([voiceId]) { return !renderedVoiceIds.has(voiceId); });
55105516
if (nativeEntries.length > 0) {
55115517
const nativeGroup = document.createElement('optgroup');
55125518
const nativeLabel = window.t ? window.t('character.geminiNativeVoices') : 'Gemini 原生音色';
@@ -5521,6 +5527,25 @@ async function _loadPanelVoices(selectEl, currentVoiceId) {
55215527
});
55225528
selectEl.appendChild(nativeGroup);
55235529
}
5530+
5531+
// 保底:currentVoiceId 是 Gemini 别名("中文男"、"male" 等)或本轮 catalog 没暴露
5532+
// 该 ID 时,下拉里没有匹配项 select 会回到首项;下次保存表单会被误判为
5533+
// "已清空"走 unregister_voice 分支,把用户保存的音色丢掉。这里仿 GSV 兜底,
5534+
// 给未知值补一条 "(?)" 占位条,保留原值供后端 normalize。
5535+
if (currentVoiceId
5536+
&& !selectEl.querySelector('option[value="' + CSS.escape(currentVoiceId) + '"]')) {
5537+
const fallbackGroup = document.createElement('optgroup');
5538+
const fallbackLabel = window.t ? window.t('character.savedVoiceFallback') : '当前已保存音色';
5539+
fallbackGroup.label = '── ' + fallbackLabel + ' ──';
5540+
fallbackGroup.dataset.geminiFallbackGroup = 'true';
5541+
const fallbackOption = document.createElement('option');
5542+
fallbackOption.value = currentVoiceId;
5543+
fallbackOption.textContent = currentVoiceId + ' (?)';
5544+
fallbackOption.title = currentVoiceId;
5545+
fallbackOption.selected = true;
5546+
fallbackGroup.appendChild(fallbackOption);
5547+
selectEl.appendChild(fallbackGroup);
5548+
}
55245549
}
55255550
}
55265551

static/js/voice_clone.js

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1255,12 +1255,17 @@ async function loadVoices() {
12551255
}
12561256

12571257
// 渲染 Gemini 原生音色(CORE_API_TYPE=gemini 时由后端注入)
1258-
// 与已注册自定义音色冲突时(如用户克隆了名为 Puck 的音色),优先保留自定义条目,
1259-
// 与 _has_custom_tts 路由优先级保持一致
1258+
// 去重范围:自定义注册音色 + 免费预设音色 ID,避免冲突时列表里重复条目和多重选中态。
1259+
// 自定义/免费音色优先保留,与 _has_custom_tts 的路由优先级一致
12601260
if (data.native_voices && Object.keys(data.native_voices).length > 0) {
1261-
const registeredVoiceIds = new Set(voicesArray.map((v) => v.voiceId));
1261+
const renderedVoiceIds = new Set(voicesArray.map((v) => v.voiceId));
1262+
if (data.free_voices) {
1263+
Object.values(data.free_voices).forEach((id) => {
1264+
if (id) renderedVoiceIds.add(String(id));
1265+
});
1266+
}
12621267
const nativeEntries = Object.entries(data.native_voices)
1263-
.filter(([voiceId]) => !registeredVoiceIds.has(voiceId));
1268+
.filter(([voiceId]) => !renderedVoiceIds.has(voiceId));
12641269
if (nativeEntries.length > 0) {
12651270
const hasPriorContent = voicesArray.length > 0
12661271
|| (data.free_voices && Object.keys(data.free_voices).length > 0);

static/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1861,6 +1861,7 @@
18611861
"switchError": "An error occurred while switching neko",
18621862
"freePresetVoices": "Free Preset Voices",
18631863
"geminiNativeVoices": "Gemini Native Voices",
1864+
"savedVoiceFallback": "Currently saved voice",
18641865
"gptsovitsVoices": "GPT-SoVITS Voices",
18651866
"loadFailed": "Failed to load character data",
18661867
"renameError": "Rename failed: {{error}}",

static/locales/es.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1861,6 +1861,7 @@
18611861
"switchError": "Se produjo un error al cambiar de Neko.",
18621862
"freePresetVoices": "Voces preestablecidas gratuitas",
18631863
"geminiNativeVoices": "Voces nativas de Gemini",
1864+
"savedVoiceFallback": "Voz actualmente guardada",
18641865
"gptsovitsVoices": "Voces GPT-SoVITS",
18651866
"loadFailed": "No se pudieron cargar los datos del personaje",
18661867
"renameError": "Error al cambiar el nombre: {{error}}",

static/locales/ja.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1861,6 +1861,7 @@
18611861
"switchError": "neko の切り替え中にエラーが発生しました",
18621862
"freePresetVoices": "── 無料プリセット音色 ──",
18631863
"geminiNativeVoices": "Gemini ネイティブ音声",
1864+
"savedVoiceFallback": "現在保存中の音声",
18641865
"gptsovitsVoices": "GPT-SoVITS 音声",
18651866
"loadFailed": "キャラクターデータの読み込みに失敗しました",
18661867
"renameError": "名前の変更に失敗しました: {{error}}",

static/locales/ko.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1861,6 +1861,7 @@
18611861
"switchError": "네코를 전환하는 동안 오류가 발생했습니다.",
18621862
"freePresetVoices": "── 무료 사전 설정 음색 ──",
18631863
"geminiNativeVoices": "Gemini 네이티브 음성",
1864+
"savedVoiceFallback": "현재 저장된 음성",
18641865
"gptsovitsVoices": "GPT-SoVITS 음성",
18651866
"loadFailed": "캐릭터 데이터 로드 실패",
18661867
"renameError": "이름 변경 실패: {{error}}",

static/locales/pt.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1861,6 +1861,7 @@
18611861
"switchError": "Ocorreu um erro ao trocar de neko",
18621862
"freePresetVoices": "Vozes predefinidas gratuitas",
18631863
"geminiNativeVoices": "Vozes nativas do Gemini",
1864+
"savedVoiceFallback": "Voz atualmente salva",
18641865
"gptsovitsVoices": "Vozes GPT-SoVITS",
18651866
"loadFailed": "Falha ao carregar dados de caracteres",
18661867
"renameError": "Falha ao renomear: {{error}}",

static/locales/ru.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1861,6 +1861,7 @@
18611861
"switchError": "Ошибка при переключении NEKO",
18621862
"freePresetVoices": "Бесплатные предустановленные голоса",
18631863
"geminiNativeVoices": "Нативные голоса Gemini",
1864+
"savedVoiceFallback": "Текущий сохранённый голос",
18641865
"gptsovitsVoices": "Голоса GPT-SoVITS",
18651866
"loadFailed": "Ошибка загрузки данных персонажа",
18661867
"renameError": "Ошибка переименования: {{error}}",

static/locales/zh-CN.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1861,6 +1861,7 @@
18611861
"switchError": "切换猫娘时发生错误",
18621862
"freePresetVoices": "免费预设音色",
18631863
"geminiNativeVoices": "Gemini 原生音色",
1864+
"savedVoiceFallback": "当前已保存音色",
18641865
"gptsovitsVoices": "GPT-SoVITS 声音",
18651866
"loadFailed": "加载角色数据失败",
18661867
"renameError": "重命名失败: {{error}}",

0 commit comments

Comments
 (0)