diff --git a/README.md b/README.md index 19dab6f..a08c447 100755 --- a/README.md +++ b/README.md @@ -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` 模型。 diff --git a/aiia_podcast_nodes.py b/aiia_podcast_nodes.py index b94aa47..4e2684a 100755 --- a/aiia_podcast_nodes.py +++ b/aiia_podcast_nodes.py @@ -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 { diff --git a/aiia_qwen_nodes.py b/aiia_qwen_nodes.py index d16b85e..97881da 100644 --- a/aiia_qwen_nodes.py +++ b/aiia_qwen_nodes.py @@ -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)" + }