refactor: simplify Qwen3-TTS UI with Model Router and bundle support

This commit is contained in:
Hawk Lee
2026-02-04 15:22:22 +08:00
parent c4aefcf541
commit 5b43ddd45f
3 changed files with 116 additions and 48 deletions
+13 -6
View File
@@ -985,8 +985,9 @@ hf download digital-avatar/ditto-talkinghead --local-dir ditto
- **Flash Attention 2**: 强烈推荐以获得最佳推理性能。
- **节点**:
- `🤖 Qwen3-TTS Loader`: 加载模型。支持 `Base (Clone)`、`CustomVoice (Presets)` 和 `VoiceDesign` 模型。
- `🗣️ Qwen3-TTS Synthesis`: 执行合成。根据加载的模型类型自动切换功能。
- `🎙️ Qwen3-TTS Dialogue (Specialist)`: **[旗舰级]** 专为 Qwen3 设计的对话节点。支持为每个角色独立设置克隆、预设或设计模式,真正发挥全系列模型优势。
- `🗣️ Qwen3-TTS Synthesis`: 执行合成。支持单模型连接或通过 Router 连接的 Bundle。
- `🔌 Qwen3-Model Router (Bundle)`: **[新]** 路由节点。将多个分立的 Qwen 模型捆绑成一个,供对话节点自动调用。
- `🎙️ Qwen3-TTS Dialogue (Specialist)`: **[旗帜级]** 专为 Qwen3 设计的对话节点。单输入设计,支持通过 Router 实现混合克隆/捏人。
- **模型列表**:
- `Qwen/Qwen3-TTS-12Hz-1.7B-Base` (或 0.6B-Base)
- `Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice` (或 0.6B-CustomVoice)
@@ -994,13 +995,19 @@ hf download digital-avatar/ditto-talkinghead --local-dir ditto
#### 📊 模型功能映射表 (Model Capability Mapping)
| 模型版本 | **音色克隆 (Clone)** | **预设音色 (Presets)** | **文字捏人 (Design)** | **方言支持 (Dialect)** |
| 模型版本 | **音色克隆 (Clone)** | **情感控制 (Emotion)** | **文字捏人 (Design)** | **方言支持 (Dialect)** |
| :--- | :---: | :---: | :---: | :---: |
| **Base** (1.7B/0.6B) | **👑 最强** | ❌ 不支持 | ❌ 不支持 | ⚠️ 仅限录音自带 |
| **CustomVoice** (1.7B) | ⚠️ 效果极差 | **👑 最佳** | ⚠️ 指令干扰严重 | ⚠️ 效果一般 |
| **VoiceDesign** (1.7B) | ❌ 不支持 | ⚠️ 部分支持 | **👑 专家** | **👑 完美支持** |
| **Base** (1.7B/0.6B) | **👑 最强** | ❌ 仅限录音自带 | ❌ 不支持 | ⚠️ 仅限录音自带 |
| **CustomVoice** (1.7B) | ⚠️ 效果极差 | ✅ 支持 | ⚠️ 指令干扰严重 | ⚠️ 效果一般 |
| **VoiceDesign** (1.7B) | ❌ 不支持 | **👑 专家** | **👑 专家** | **👑 完美支持** |
| **CustomVoice** (0.6B) | ⚠️ 效果极差 | ✅ 支持 | ✅ 表现优异 | ✅ 表现优异 |
> [!TIP]
> **关于 UI 简化**:
> 现在的对话节点只有一个 `qwen_model` 输入槽。
> - 如果你只需要一种模型,直接连上即可。
> - 如果你想实现“Speaker A 克隆,Speaker B 捏人”的混合效果,请使用 `🔌 Qwen3-Model Router` 节点进行打包连接。
> [!IMPORTANT]
> **结论**:
> 1. 做 **3秒音色克隆**:必须连 `Base` 模型。
+9 -13
View File
@@ -195,21 +195,16 @@ class AIIA_Dialogue_TTS:
"pause_duration": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 5.0, "step": 0.1}),
"speed_global": ("FLOAT", {"default": 1.0, "min": 0.5, "max": 2.0}),
"batch_mode": (["Natural (Hybrid)", "Strict (Per-Speaker)", "Whole (Single Batch)"], {"default": "Natural (Hybrid)"}),
"qwen_model": ("QWEN_MODEL",), # Primary Qwen model
# VibeVoice Specific Params
"max_batch_char": ("INT", {"default": 1000, "min": 100, "max": 32768}),
"cfg_scale": ("FLOAT", {"default": 1.5, "min": 1.0, "max": 10.0, "step": 0.1}),
"temperature": ("FLOAT", {"default": 0.8, "min": 0.1, "max": 2.0}),
"top_k": ("INT", {"default": 20, "min": 0, "max": 100}),
"top_p": ("FLOAT", {"default": 0.95, "min": 0.0, "max": 1.0, "step": 0.05}),
"max_batch_char": ("INT", {"default": 1000, "min": 100, "max": 32768}),
},
"optional": {
"cosyvoice_model": ("COSYVOICE_MODEL",),
"vibevoice_model": ("VIBEVOICE_MODEL",),
"qwen_base_model": ("QWEN_MODEL",), # Optional specialized Base
"qwen_custom_model": ("QWEN_MODEL",), # Optional specialized CustomVoice
"qwen_design_model": ("QWEN_MODEL",), # Optional specialized VoiceDesign
"qwen_model": ("QWEN_MODEL",), # Primary Qwen model (can be a Bundle)
# Speaker A
"speaker_A_ref": ("AUDIO",),
@@ -348,9 +343,8 @@ class AIIA_Dialogue_TTS:
def process_dialogue(self, dialogue_json, tts_engine, pause_duration, speed_global, batch_mode,
qwen_model=None, cosyvoice_model=None, vibevoice_model=None,
qwen_base_model=None, qwen_custom_model=None, qwen_design_model=None,
cfg_scale=1.5, temperature=0.8, top_k=20, top_p=0.95, max_batch_char=1000, **kwargs):
max_batch_char=1000, cfg_scale=1.5, temperature=0.8, top_k=20, top_p=0.95,
qwen_model=None, cosyvoice_model=None, vibevoice_model=None, **kwargs):
# Robustness: ensure max_batch_char is correctly picked up even if shifted or provided as kwarg
max_batch_char = kwargs.get("max_batch_char", max_batch_char)
import json
@@ -533,10 +527,12 @@ class AIIA_Dialogue_TTS:
me = f"{me},{el}" if me else el
ins = f"{me}。" if me else ""
# Routing: Use bundle if available, else use the single connected model
tm = qwen_model
if ref is not None and qwen_base_model is not None: tm = qwen_base_model
elif ins and qwen_design_model is not None and ref is None: tm = qwen_design_model
elif qwen_custom_model is not None: tm = qwen_custom_model
if qwen_model.get("is_bundle"):
if ref is not None: tm = qwen_model.get("base") or qwen_model.get("default")
elif ins: tm = qwen_model.get("design") or qwen_model.get("default")
else: tm = qwen_model.get("custom") or qwen_model.get("default")
# Dialect is part of compatibility
return {
+94 -29
View File
@@ -186,9 +186,23 @@ class AIIA_Qwen_TTS:
CATEGORY = "AIIA/Synthesis"
def generate(self, qwen_model, text, language, speaker="Vivian", instruct="", reference_audio=None, reference_text="", zero_shot_mode=False, emotion="None", dialect="None", seed=42, speed=1.0, cfg_scale=1.5, temperature=0.8, top_k=20, top_p=0.95):
model = qwen_model["model"]
m_type = qwen_model["type"]
model_path = qwen_model.get("path", "").lower()
# 0. Handle Bundle Routing
active_qwen = qwen_model
if qwen_model.get("is_bundle"):
# Auto-route based on generation intent
if reference_audio is not None or zero_shot_mode:
active_qwen = qwen_model.get("base") or qwen_model.get("default")
elif instruct.strip() or dialect != "None" or emotion != "None":
active_qwen = qwen_model.get("design") or qwen_model.get("custom") or qwen_model.get("default")
else:
active_qwen = qwen_model.get("custom") or qwen_model.get("default")
if not active_qwen:
raise ValueError("[AIIA Qwen] No active model found in bundle or input!")
model = active_qwen["model"]
m_type = active_qwen["type"]
model_path = active_qwen.get("path", "").lower()
# Warning for Base model with instruct
if "base" in model_path and (instruct.strip() or dialect != "None" or emotion != "None"):
@@ -319,19 +333,12 @@ class AIIA_Qwen_Dialogue_TTS:
"cfg_scale": ("FLOAT", {"default": 1.5, "min": 1.0, "max": 10.0, "step": 0.1}),
"temperature": ("FLOAT", {"default": 0.8, "min": 0.1, "max": 2.0, "step": 0.1}),
"top_k": ("INT", {"default": 20, "min": 0, "max": 100}),
"top_p": ("FLOAT", {"default": 0.95, "min": 0.0, "max": 1.0, "step": 0.05}),
"zero_shot_mode": ("BOOLEAN", {"default": False}),
"max_batch_char": ("INT", {"default": 1000, "min": 100, "max": 32768}),
"dialect_note": ("STRING", {"default": "💡 提示:方言建议配合 Design 模式使用。", "is_label": True}),
"base_note": ("STRING", {"default": "⚠️ 注意:Clone 模式下的 Base 模型不支持文字指令控制。", "is_label": True}),
},
"optional": {
"qwen_base_model": ("QWEN_MODEL",),
"qwen_custom_model": ("QWEN_MODEL",),
"qwen_design_model": ("QWEN_MODEL",),
"qwen_model": ("QWEN_MODEL",),
"preset_note": ("STRING", {"default": QWEN_PRESET_NOTE, "is_label": True}),
# Speaker A
"speaker_A_mode": (["Clone", "Preset", "Design"], {"default": "Clone"}),
"speaker_A_id": (QWEN_SPEAKER_LIST, {"default": "Vivian"}),
@@ -341,7 +348,6 @@ class AIIA_Qwen_Dialogue_TTS:
"speaker_A_design": ("STRING", {"multiline": True, "default": ""}),
"speaker_A_ref": ("AUDIO",),
"speaker_A_ref_text": ("STRING", {"multiline": True, "default": ""}),
# Speaker B
"speaker_B_mode": (["Clone", "Preset", "Design"], {"default": "Clone"}),
"speaker_B_id": (QWEN_SPEAKER_LIST, {"default": "Vivian"}),
@@ -351,7 +357,6 @@ class AIIA_Qwen_Dialogue_TTS:
"speaker_B_design": ("STRING", {"multiline": True, "default": ""}),
"speaker_B_ref": ("AUDIO",),
"speaker_B_ref_text": ("STRING", {"multiline": True, "default": ""}),
# Speaker C
"speaker_C_mode": (["Clone", "Preset", "Design"], {"default": "Design"}),
"speaker_C_id": (QWEN_SPEAKER_LIST, {"default": "Vivian"}),
@@ -469,31 +474,50 @@ class AIIA_Qwen_Dialogue_TTS:
spk_expression_preset = kwargs.get(f"speaker_{spk_key}_expression", "None")
spk_dialect_preset = kwargs.get(f"speaker_{spk_key}_dialect", "None")
design = kwargs.get(f"speaker_{spk_key}_design", "")
qwen_model = kwargs.get("qwen_model")
if qwen_model is None:
raise ValueError("AIIA_Qwen_Dialogue_TTS: qwen_model is required. Connect a single Qwen model or a Qwen3 Model Router (Bundle)!")
# 0. Specialized Qwen Routing from Bundle
def get_model_from_bundle(mode, ref=None):
if not qwen_model.get("is_bundle"): return qwen_model
if mode == "Clone" or ref is not None:
return qwen_model.get("base") or qwen_model.get("default")
elif mode == "Design":
return qwen_model.get("design") or qwen_model.get("default")
else: # Preset
return qwen_model.get("custom") or qwen_model.get("default")
# Extract Speaker Params
def get_speaker_params(prefix):
mode = kwargs.get(f"{prefix}_mode", "Preset")
ref = kwargs.get(f"{prefix}_ref")
tm = get_model_from_bundle(mode, ref)
return {
"tm": tm,
"id": kwargs.get(f"{prefix}_id", "Vivian"),
"ref": ref,
"exp": kwargs.get(f"{prefix}_expression", ""),
"dialect": kwargs.get(f"{prefix}_dialect", "None")
}
ref_audio = get_ref_audio_with_fallback(spk_key) if mode == "Clone" else None
ref_text = kwargs.get(f"speaker_{spk_key}_ref_text", "")
qwen_model = None
if mode == "Clone":
qwen_model = kwargs.get("qwen_base_model") or kwargs.get("qwen_custom_model")
elif mode == "Preset":
qwen_model = kwargs.get("qwen_custom_model")
elif mode == "Design":
qwen_model = kwargs.get("qwen_design_model")
if qwen_model is None:
qwen_model = kwargs.get("qwen_base_model") or kwargs.get("qwen_custom_model") or kwargs.get("qwen_design_model")
# Determine the actual Qwen model to use for this segment
segment_qwen_model = get_model_from_bundle(mode, ref_audio)
# Unique key for "homogeneity"
# For Preset, we can merge DIFFERENT speakers by using [Speaker] tags
# So they only need to share the same qwen_model and mode="Preset"
if mode == "Preset":
param_hash = (f"Preset_{id(qwen_model)}", spk_dialect_preset)
param_hash = (f"Preset_{id(segment_qwen_model)}", spk_dialect_preset)
elif mode == "Clone":
# Must share same ref_audio and ref_text and dialect
param_hash = (f"Clone_{id(qwen_model)}", id(ref_audio), ref_text, spk_dialect_preset)
param_hash = (f"Clone_{id(segment_qwen_model)}", id(ref_audio), ref_text, spk_dialect_preset)
else: # Design
# Must share the same design text and dialect
param_hash = (f"Design_{id(qwen_model)}", design, spk_dialect_preset)
param_hash = (f"Design_{id(segment_qwen_model)}", design, spk_dialect_preset)
return {
"spk_name": spk_name,
@@ -505,7 +529,7 @@ class AIIA_Qwen_Dialogue_TTS:
"spk_expression_preset": spk_expression_preset,
"spk_dialect_preset": spk_dialect_preset,
"mode": mode,
"qwen_model": qwen_model,
"qwen_model": segment_qwen_model, # Use the resolved model
"ref_audio": ref_audio,
"ref_text": ref_text,
"design": design,
@@ -681,14 +705,55 @@ class AIIA_Qwen_Dialogue_TTS:
final_wav = torch.cat(full_waveform, dim=1)
return ({"waveform": final_wav.unsqueeze(0), "sample_rate": sample_rate}, json.dumps(segments_info, ensure_ascii=False))
class AIIA_Qwen_Model_Router:
@classmethod
def INPUT_TYPES(s):
return {
"required": {},
"optional": {
"qwen_default": ("QWEN_MODEL",),
"qwen_base": ("QWEN_MODEL",),
"qwen_custom": ("QWEN_MODEL",),
"qwen_design": ("QWEN_MODEL",),
}
}
RETURN_TYPES = ("QWEN_MODEL",)
RETURN_NAMES = ("qwen_bundle",)
FUNCTION = "bundle"
CATEGORY = "AIIA/Loaders"
def bundle(self, **kwargs):
qwen_default = kwargs.get("qwen_default")
qwen_base = kwargs.get("qwen_base")
qwen_custom = kwargs.get("qwen_custom")
qwen_design = kwargs.get("qwen_design")
if all(m is None for m in [qwen_default, qwen_base, qwen_custom, qwen_design]):
raise ValueError("[AIIA Qwen Router] At least one Qwen model must be connected!")
bundle = {
"is_bundle": True,
"default": qwen_default or qwen_base or qwen_custom or qwen_design,
"base": qwen_base,
"custom": qwen_custom,
"design": qwen_design,
"path": (qwen_default or qwen_base or qwen_custom or qwen_design).get("path", "")
}
return (bundle,)
NODE_CLASS_MAPPINGS = {
"AIIA_Qwen_Loader": AIIA_Qwen_Loader,
"AIIA_Qwen_TTS": AIIA_Qwen_TTS,
"AIIA_Qwen_Dialogue_TTS": AIIA_Qwen_Dialogue_TTS
"AIIA_Qwen_Dialogue_TTS": AIIA_Qwen_Dialogue_TTS,
"AIIA_Qwen_Model_Router": AIIA_Qwen_Model_Router
}
NODE_DISPLAY_NAME_MAPPINGS = {
"AIIA_Qwen_Loader": "🤖 Qwen3-TTS Loader",
"AIIA_Qwen_TTS": "🗣️ Qwen3-TTS Synthesis",
"AIIA_Qwen_Dialogue_TTS": "🎙️ Qwen3-TTS Dialogue (Specialist)"
"AIIA_Qwen_Dialogue_TTS": "🎙️ Qwen3-TTS Dialogue (Specialist)",
"AIIA_Qwen_Model_Router": "🔌 Qwen3-Model Router (Bundle)"
}