feat: release version 1.15.0 with IndexTTS-2 inline emotion tags and fixes
This commit is contained in:
@@ -1107,24 +1107,85 @@ Qwen3-TTS 最强大的特性之一是其**自然语言指令驱动**的能力。
|
||||
#### 3.14 IndexTTS-2 (New! 🔥)
|
||||
|
||||
- **用途**: Bilibili 开源的 **零样本语音克隆 + 情感控制** TTS 模型。支持**音色与情感解耦**,调整情感时不会导致音色漂移。
|
||||
|
||||
- **🔥 核心特性**:
|
||||
- **音色/情感解耦 (Voice-Emotion Decoupling)**: 独立控制音色和情感,改变情感不影响声音音色。这解决了其他模型在调整情感时音色漂移的顽疾。
|
||||
- **8 维情感向量控制**: 提供 `happy`, `angry`, `sad`, `afraid`, `disgusted`, `melancholic`, `surprised`, `calm` 八个情感滑块,精细控制输出语音的情感表达。
|
||||
- **🆕 内联情感标签 (Inline Emotion Tags)**: 支持在文本中嵌入 `[Happy]`、`[Sad]` 等标签,**逐句控制不同情感**。节点自动按标签分段生成,再以余弦淡入淡出 + 静音间隔无缝拼接。
|
||||
- **情感音频参考**: 支持通过额外的 `emotion_audio` 输入捕获参考音频中的情感特征,并通过 `emo_alpha` 控制混合强度。
|
||||
- **QwenEmotion 自动推断**: 启用 `use_emo_text` 后,内置 Qwen 情感模型自动分析文本语义,推断最合适的情感向量。
|
||||
- **零样本音色克隆**: 输入 `reference_audio` 即可克隆声音。
|
||||
|
||||
- **节点**:
|
||||
- **IndexTTS-2 Loader**: 加载 IndexTTS-2 模型。
|
||||
- `use_fp16`: 半精度推理(降低 VRAM,加速推理)。
|
||||
- `use_cuda_kernel`: 使用 BigVGAN 自定义 CUDA 核心(仅 NVIDIA GPU)。
|
||||
- `model_dir` (可选): 自定义模型目录,留空则使用默认路径 `ComfyUI/models/indextts2/`。
|
||||
- **IndexTTS-2 TTS**: 执行语音合成。
|
||||
- `text`: 待合成的文本(支持中英文混合)。
|
||||
- `reference_audio`: 音色参考音频。
|
||||
- `emotion_audio` (可选): 独立的情感参考音频。
|
||||
- 8 个情感滑块 (`happy` ~ `calm`):直接控制情感向量。
|
||||
- `emo_alpha`: 情感混合强度 (0=无情感, 1=完全情感)。
|
||||
- `use_random`: 随机采样(开启后降低克隆保真度)。
|
||||
- `seed`: 随机种子,用于可复现生成。
|
||||
|
||||
**IndexTTS-2 Loader** — 加载 IndexTTS-2 模型
|
||||
|
||||
| 参数 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| `use_fp16` | `True` | 半精度推理,降低 VRAM 占用 |
|
||||
| `use_cuda_kernel` | `True` | BigVGAN CUDA 核心加速(仅 NVIDIA GPU,推理更快) |
|
||||
| `model_dir` | 空 | 自定义模型路径,留空使用 `ComfyUI/models/indextts2/` |
|
||||
|
||||
**IndexTTS-2 TTS** — 执行语音合成
|
||||
|
||||
| 参数 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| `text` | — | 待合成文本,支持中英文混合及 **内联情感标签** |
|
||||
| `voice_preset` | `Female_HQ` | 内置音色预设(在无 `reference_audio` 时使用) |
|
||||
| `reference_audio` | — | 音色参考音频(零样本克隆,优先于 preset) |
|
||||
| `emotion_audio` | — | 独立的情感参考音频 |
|
||||
| `emo_alpha` | `1.0` | 情感混合强度 (0=无情感, 1=100%情感) |
|
||||
| `happy` ~ `calm` | `0.0` | 8 个情感滑块,直接控制向量 |
|
||||
| `use_emo_text` | `False` | 启用后使用 Qwen 模型自动推断情感 |
|
||||
| `emo_text` | 空 | 自定义情感提示文本(配合 `use_emo_text`) |
|
||||
| `interval_silence` | `200` | 长文本分段间静音时长 (ms) |
|
||||
| `max_text_tokens_per_segment` | `120` | 每段最大 token 数(控制切分粒度) |
|
||||
| `use_random` | `False` | 随机采样(降低克隆保真度) |
|
||||
| `seed` | `0` | 随机种子 (-1=随机) |
|
||||
|
||||
- **🆕 内联情感标签 (Inline Emotion Tags)**:
|
||||
|
||||
在文本中嵌入 `[标签名]` 即可逐句控制情感,节点会自动按标签拆分,每段独立生成后拼接。
|
||||
|
||||
**使用示例**:
|
||||
```text
|
||||
[happy] 今天天气真好,阳光明媚!
|
||||
[sad] 但是我养的小猫走丢了,我好难过。
|
||||
[calm] 不过我相信它一定会自己找到回家的路。
|
||||
```
|
||||
→ 生成 3 段音频,分别带 happy / sad / calm 情感,最终余弦淡入淡出无缝拼接为一段完整音频。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **控制情感强度**: 内联标签默认映射到全强度的情感向量 (1.0)。如果发现音色失真或情感过于夸张(如嘶吼、破音),请尝试**降低** `emo_alpha` 参数(推荐值 **0.6 ~ 0.8**)。较低的 alpha 值能更好地保留原始音色特征,同时赋予适度的情感色彩。
|
||||
|
||||
**支持的 26 种标签**:
|
||||
|
||||
| 分类 | 标签 |
|
||||
|------|------|
|
||||
| 积极情感 | `happy`, `excited`, `enthusiastic`, `proud`, `romantic`, `innocent` |
|
||||
| 消极情感 | `sad`, `angry`, `afraid`, `fearful`, `disgusted`, `disappointed`, `anxious`, `nervous` |
|
||||
| 复合情感 | `sarcastic`, `nostalgic`, `confused`, `mysterious`, `gossip` |
|
||||
| 中性情感 | `calm`, `neutral`, `gentle`, `serious`, `lazy`, `melancholic`, `surprised` |
|
||||
|
||||
每个标签自动映射到 IndexTTS-2 的 8 维情感向量。**未知标签**(不在上述列表中的)自动回退至 QwenEmotion 模型推断。
|
||||
|
||||
> [!TIP]
|
||||
> 内联标签通常由 **AIIA Emotion Annotator** 节点自动注入到 Splitter 的输出中,无需手写。也可以手动添加来精细控制。
|
||||
|
||||
- **🔧 情感控制优先级**:
|
||||
|
||||
当多种情感来源同时存在时,按以下优先级生效:
|
||||
|
||||
| 优先级 | 来源 | 说明 |
|
||||
|:------:|------|------|
|
||||
| 1 (最高) | 内联标签 `[Tag]` | 逐句独立控制,覆盖所有其他设置 |
|
||||
| 2 | 滑块向量 | 8 个滑块全局生效(无标签时) |
|
||||
| 3 | `use_emo_text` | Qwen 自动推断(无标签无滑块时) |
|
||||
| 4 (最低) | `emotion_audio` | 从参考音频提取情感 |
|
||||
|
||||
> [!NOTE]
|
||||
> 如果文本含标签,则标签内的段落使用标签情感;**无标签的段落**继续使用滑块或其他全局设置。
|
||||
|
||||
- **✅ 依赖版本兼容性 (Dependency Compatibility)**:
|
||||
AIIA 已内置深度兼容层,**完美支持 `transformers` 4.57+**。
|
||||
|
||||
@@ -1192,7 +1253,7 @@ Qwen3-TTS 最强大的特性之一是其**自然语言指令驱动**的能力。
|
||||
- **追求“听起来最像真人” (音质+音色)**: 选 **VoxCPM 1.5**。它的 Tokenizer-free 架构带来了质的飞跃。
|
||||
- **追求“方言/多语言/稳定性”**: 选 **CosyVoice 3.0**。目前依然是生产环境最稳的选择。
|
||||
- **追求“多样化音色设计/最新 Qwen 生态/长语音流畅度”**: 选 **Qwen3-TTS**。其 VoiceDesign 功能能让你用描述语“捏”出从未听过的声音。
|
||||
- **追求“情感精细控制/音色情感解耦”**: 选 **IndexTTS-2**。独立的 8 维情感控制和情感音频参考是其独有优势。
|
||||
- **追求“情感精细控制/音色情感解耦”**: 选 **IndexTTS-2**。独立的 8 维情感控制、内联情感标签(逐句不同情感)和情感音频参考是其独有优势。
|
||||
- **要做“长篇广播剧/播客”**: 选 **VibeVoice**。它的长窗口上下文优势依然不可替代。
|
||||
|
||||
### 4. 播客与对话生成 (Podcast & Dialogue Generation)
|
||||
|
||||
+314
-130
@@ -5,12 +5,17 @@ Loader + TTS nodes for Bilibili's IndexTTS-2 zero-shot voice cloning model.
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import contextlib
|
||||
import tempfile
|
||||
import warnings
|
||||
|
||||
import torch
|
||||
import torchaudio
|
||||
import tempfile
|
||||
import numpy as np
|
||||
import contextlib
|
||||
import shutil
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Tuple
|
||||
|
||||
# --- Preload kaldifst to prevent hang when used with NeMo Diarization ---
|
||||
# IndexTTS-2 seems to put the process (OpenMP/MKL/dlopen lock) in a state
|
||||
@@ -60,8 +65,7 @@ def _ensure_indextts():
|
||||
_install_missing_deps()
|
||||
|
||||
|
||||
# Patch transformers compatibility before any indextts import
|
||||
# _patch_transformers_compat() -> Now using context manager in load/infer
|
||||
|
||||
|
||||
|
||||
_INDEXTTS_READY = True
|
||||
@@ -73,89 +77,7 @@ def _ensure_indextts():
|
||||
|
||||
_SENTINEL = object() # unique marker for "attribute didn't exist before"
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _transformers_patches():
|
||||
"""
|
||||
Context manager: temporarily add shims for attributes removed in newer
|
||||
transformers versions. On exit every added attribute is deleted again so
|
||||
that downstream imports (NeMo, etc.) see an unmodified transformers.
|
||||
|
||||
IndexTTS's own modules are safe because they cache references at import
|
||||
time via ``from X import Y`` — those bindings survive the delattr.
|
||||
"""
|
||||
|
||||
from transformers import cache_utils
|
||||
from transformers.generation import candidate_generator as cg
|
||||
from transformers.generation import configuration_utils as cu
|
||||
import transformers.modeling_utils as mu
|
||||
from transformers import GenerationConfig
|
||||
|
||||
# ---- build the list of (module, attr_name, shim_value) ---------------
|
||||
class _QCC:
|
||||
def __init__(self, **kw): pass
|
||||
|
||||
def _crop(model, past_key_values, max_length):
|
||||
return past_key_values
|
||||
|
||||
class _SeqSummary(torch.nn.Module):
|
||||
def __init__(self, config): super().__init__()
|
||||
def forward(self, hidden_states, **kw): return hidden_states[:, -1]
|
||||
|
||||
def _chunking(forward_fn, chunk_size, *tensors, **kw):
|
||||
return forward_fn(*tensors, **kw)
|
||||
|
||||
patches = [
|
||||
(cache_utils, "QuantizedCacheConfig", _QCC),
|
||||
(cg, "_crop_past_key_values", _crop),
|
||||
(cu, "NEED_SETUP_CACHE_CLASSES_MAPPING", {}),
|
||||
(cu, "QUANT_BACKEND_CLASSES_MAPPING", {}),
|
||||
(mu, "SequenceSummary", _SeqSummary),
|
||||
(mu, "apply_chunking_to_forward", _chunking),
|
||||
]
|
||||
|
||||
# GenerationConfig.forced_decoder_ids (class-level attribute)
|
||||
gc_had = hasattr(GenerationConfig, "forced_decoder_ids")
|
||||
gc_old = getattr(GenerationConfig, "forced_decoder_ids", _SENTINEL)
|
||||
|
||||
# ---- apply (only if the attribute is absent) -------------------------
|
||||
originals = [] # (module, attr_name, old_value_or_SENTINEL)
|
||||
count = 0
|
||||
for mod, attr, shim in patches:
|
||||
old = getattr(mod, attr, _SENTINEL)
|
||||
originals.append((mod, attr, old))
|
||||
if old is _SENTINEL:
|
||||
setattr(mod, attr, shim)
|
||||
count += 1
|
||||
|
||||
if not gc_had:
|
||||
setattr(GenerationConfig, "forced_decoder_ids", None)
|
||||
count += 1
|
||||
|
||||
if count > 0:
|
||||
print(f"[AIIA] Applied {count} transformers compatibility patches.")
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# ---- revert: remove everything we added -------------------------
|
||||
reverted = 0
|
||||
for mod, attr, old in originals:
|
||||
if old is _SENTINEL:
|
||||
# we added it → delete it
|
||||
if hasattr(mod, attr):
|
||||
delattr(mod, attr)
|
||||
reverted += 1
|
||||
# else: attribute existed before us, leave it alone
|
||||
|
||||
if not gc_had:
|
||||
try:
|
||||
delattr(GenerationConfig, "forced_decoder_ids")
|
||||
reverted += 1
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
if reverted > 0:
|
||||
print(f"[AIIA] Reverted {reverted} transformers compatibility patches.")
|
||||
|
||||
|
||||
|
||||
@@ -280,7 +202,7 @@ class AIIA_IndexTTS2_Loader:
|
||||
return {
|
||||
"required": {
|
||||
"use_fp16": ("BOOLEAN", {"default": True, "tooltip": "Use half-precision for lower VRAM and faster inference."}),
|
||||
"use_cuda_kernel": ("BOOLEAN", {"default": True, "tooltip": "Use BigVGAN custom CUDA kernel (faster, CUDA only)."}),
|
||||
"use_cuda_kernel": ("BOOLEAN", {"default": True, "tooltip": "Use BigVGAN custom CUDA kernel for faster vocoder inference (NVIDIA GPU only)."}),
|
||||
},
|
||||
"optional": {
|
||||
"model_dir": ("STRING", {
|
||||
@@ -338,19 +260,15 @@ class AIIA_IndexTTS2_Loader:
|
||||
|
||||
print(f"[AIIA] Loading IndexTTS-2 from {model_dir} (fp16={use_fp16}, cuda_kernel={use_cuda_kernel})")
|
||||
|
||||
# Temporarily apply transformers compatibility patches for the IndexTTS
|
||||
# import chain. They are reverted on exit so other modules (NeMo etc.)
|
||||
# see an unmodified transformers.
|
||||
with _transformers_patches():
|
||||
from indextts.infer_v2 import IndexTTS2
|
||||
with _patch_indextts_loading(model_dir):
|
||||
tts = IndexTTS2(
|
||||
cfg_path=cfg_path,
|
||||
model_dir=model_dir,
|
||||
use_fp16=use_fp16,
|
||||
use_cuda_kernel=use_cuda_kernel,
|
||||
use_deepspeed=False,
|
||||
)
|
||||
from indextts.infer_v2 import IndexTTS2
|
||||
with _patch_indextts_loading(model_dir):
|
||||
tts = IndexTTS2(
|
||||
cfg_path=cfg_path,
|
||||
model_dir=model_dir,
|
||||
use_fp16=use_fp16,
|
||||
use_cuda_kernel=use_cuda_kernel,
|
||||
use_deepspeed=False,
|
||||
)
|
||||
|
||||
_INDEXTTS_MODEL_CACHE[cache_key] = tts
|
||||
print("[AIIA] IndexTTS-2 loaded successfully.")
|
||||
@@ -358,11 +276,159 @@ class AIIA_IndexTTS2_Loader:
|
||||
return (tts,)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# EMOTION TAG MAPPING
|
||||
# ============================================================================
|
||||
|
||||
# Emotion tag → IndexTTS-2 8-dim vector [happy, angry, sad, afraid, disgusted, melancholic, surprised, calm]
|
||||
_EMOTION_TAG_TO_VECTOR = {
|
||||
"happy": [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
|
||||
"excited": [0.8, 0.0, 0.0, 0.0, 0.0, 0.0, 0.3, 0.0],
|
||||
"enthusiastic": [0.9, 0.0, 0.0, 0.0, 0.0, 0.0, 0.2, 0.0],
|
||||
"proud": [0.6, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.3],
|
||||
"angry": [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
|
||||
"sad": [0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
|
||||
"disappointed": [0.0, 0.0, 0.7, 0.0, 0.0, 0.3, 0.0, 0.0],
|
||||
"nostalgic": [0.0, 0.0, 0.4, 0.0, 0.0, 0.5, 0.0, 0.1],
|
||||
"afraid": [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0],
|
||||
"fearful": [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0],
|
||||
"anxious": [0.0, 0.0, 0.0, 0.6, 0.0, 0.0, 0.2, 0.0],
|
||||
"nervous": [0.0, 0.0, 0.0, 0.5, 0.0, 0.0, 0.3, 0.0],
|
||||
"disgusted": [0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0],
|
||||
"sarcastic": [0.0, 0.3, 0.0, 0.0, 0.4, 0.0, 0.0, 0.0],
|
||||
"melancholic": [0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0],
|
||||
"surprised": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
|
||||
"confused": [0.0, 0.0, 0.0, 0.2, 0.0, 0.0, 0.6, 0.0],
|
||||
"mysterious": [0.0, 0.0, 0.0, 0.2, 0.0, 0.3, 0.0, 0.3],
|
||||
"calm": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0],
|
||||
"gentle": [0.2, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.7],
|
||||
"neutral": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5],
|
||||
"serious": [0.0, 0.2, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5],
|
||||
"romantic": [0.4, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.4],
|
||||
"lazy": [0.0, 0.0, 0.0, 0.0, 0.0, 0.3, 0.0, 0.5],
|
||||
"gossip": [0.3, 0.0, 0.0, 0.0, 0.0, 0.0, 0.4, 0.0],
|
||||
"innocent": [0.3, 0.0, 0.0, 0.0, 0.0, 0.0, 0.3, 0.3],
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TTS NODE
|
||||
# ============================================================================
|
||||
|
||||
class AIIA_IndexTTS2_TTS:
|
||||
# Match emotion tags injected by Splitter/Annotator, e.g. [Happy], [Calm]
|
||||
_EMOTION_TAG_RE = re.compile(r'^\[(\w[\w\s]*?)\]\s*', re.MULTILINE)
|
||||
|
||||
@staticmethod
|
||||
def _split_by_emotion_tags(text: str) -> List[Tuple[Optional[str], str]]:
|
||||
"""
|
||||
Split text by lines.
|
||||
- Lines starting with `[Tag]` get that emotion.
|
||||
- Lines without a tag get None (No Emotion). I.e. NO Inheritance.
|
||||
- Consecutive lines with the same emotion are merged.
|
||||
|
||||
Input:
|
||||
[Happy] Line 1
|
||||
Line 2 (No tag -> None)
|
||||
[Happy] Line 3
|
||||
[Happy] Line 4
|
||||
|
||||
Output:
|
||||
[("Happy", "Line 1"), (None, "Line 2"), ("Happy", "Line 3\\nLine 4")]
|
||||
"""
|
||||
# Regex to match leading tag: [Happy] ...
|
||||
tag_pattern = re.compile(r'^\s*\[([\w\s]+?)\]\s*(.*)$')
|
||||
|
||||
lines = text.split('\n')
|
||||
raw_segments = []
|
||||
|
||||
for line in lines:
|
||||
m = tag_pattern.match(line)
|
||||
if m:
|
||||
tag = m.group(1)
|
||||
content = m.group(2)
|
||||
raw_segments.append((tag, content))
|
||||
else:
|
||||
raw_segments.append((None, line))
|
||||
|
||||
if not raw_segments:
|
||||
return [(None, text)]
|
||||
|
||||
# Merge consecutive segments with the same emotion (case-insensitive)
|
||||
merged = [raw_segments[0]]
|
||||
for tag, txt in raw_segments[1:]:
|
||||
prev_tag, prev_txt = merged[-1]
|
||||
|
||||
same_emo = False
|
||||
if prev_tag is None and tag is None:
|
||||
same_emo = True
|
||||
elif prev_tag is not None and tag is not None:
|
||||
if prev_tag.strip().lower() == tag.strip().lower():
|
||||
same_emo = True
|
||||
|
||||
if same_emo:
|
||||
merged[-1] = (prev_tag, prev_txt + "\n" + txt)
|
||||
else:
|
||||
merged.append((tag, txt))
|
||||
|
||||
# Filter out purely empty segments if they have a tag (avoid generating silence for just tags)
|
||||
# But keep None segments (for pauses)
|
||||
final_segments = []
|
||||
for tag, txt in merged:
|
||||
if txt.strip():
|
||||
final_segments.append((tag, txt))
|
||||
|
||||
if not final_segments:
|
||||
return [(None, text)]
|
||||
|
||||
return final_segments
|
||||
|
||||
@staticmethod
|
||||
def _crossfade_segments(waveforms: list, sample_rate: int, crossfade_ms: int = 50, silence_ms: int = 100) -> torch.Tensor:
|
||||
"""
|
||||
Join waveform segments with cosine fade-out / silence gap / fade-in.
|
||||
Each waveform is (C, N).
|
||||
"""
|
||||
if not waveforms:
|
||||
return torch.zeros(1, 0)
|
||||
if len(waveforms) == 1:
|
||||
return waveforms[0]
|
||||
|
||||
xfade_samples = int(sample_rate * crossfade_ms / 1000)
|
||||
silence_samples = int(sample_rate * silence_ms / 1000)
|
||||
|
||||
result = waveforms[0]
|
||||
for i in range(1, len(waveforms)):
|
||||
curr = waveforms[i]
|
||||
|
||||
# Fade out the tail of previous segment
|
||||
fo_len = min(xfade_samples, result.shape[-1])
|
||||
if fo_len >= 2:
|
||||
t_fo = torch.linspace(0, np.pi, fo_len, device=result.device)
|
||||
fade_out = 0.5 * (1.0 + torch.cos(t_fo)) # 1 → 0
|
||||
result = torch.cat([
|
||||
result[..., :-fo_len],
|
||||
result[..., -fo_len:] * fade_out,
|
||||
], dim=-1)
|
||||
|
||||
# Insert silence gap
|
||||
channels = result.shape[0] if result.dim() >= 2 else 1
|
||||
silence = torch.zeros(channels, silence_samples, device=result.device)
|
||||
|
||||
# Fade in the head of next segment
|
||||
fi_len = min(xfade_samples, curr.shape[-1])
|
||||
if fi_len >= 2:
|
||||
t_fi = torch.linspace(0, np.pi, fi_len, device=curr.device)
|
||||
fade_in = 1.0 - 0.5 * (1.0 + torch.cos(t_fi)) # 0 → 1
|
||||
curr = torch.cat([
|
||||
curr[..., :fi_len] * fade_in,
|
||||
curr[..., fi_len:],
|
||||
], dim=-1)
|
||||
|
||||
result = torch.cat([result, silence, curr], dim=-1)
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
@@ -386,6 +452,22 @@ class AIIA_IndexTTS2_TTS:
|
||||
"melancholic": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.05}),
|
||||
"surprised": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.05}),
|
||||
"calm": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.05}),
|
||||
"use_emo_text": ("BOOLEAN", {
|
||||
"default": False,
|
||||
"tooltip": "Auto-detect emotion from text using built-in Qwen emotion model. Overrides emotion sliders."
|
||||
}),
|
||||
"emo_text": ("STRING", {
|
||||
"default": "",
|
||||
"tooltip": "Custom emotion text prompt (used with use_emo_text). Leave empty to use main text."
|
||||
}),
|
||||
"interval_silence": ("INT", {
|
||||
"default": 200, "min": 0, "max": 2000, "step": 50,
|
||||
"tooltip": "Silence duration (ms) inserted between text segments for long text."
|
||||
}),
|
||||
"max_text_tokens_per_segment": ("INT", {
|
||||
"default": 120, "min": 30, "max": 500, "step": 10,
|
||||
"tooltip": "Max tokens per text segment. Lower = more segments, higher = longer per-segment generation."
|
||||
}),
|
||||
"use_random": ("BOOLEAN", {
|
||||
"default": False,
|
||||
"tooltip": "Enable random sampling (reduces voice cloning fidelity)."
|
||||
@@ -444,12 +526,42 @@ class AIIA_IndexTTS2_TTS:
|
||||
tmp.close()
|
||||
return tmp.name
|
||||
|
||||
def _infer_single_segment(self, tts, text, ref_path, out_path,
|
||||
emo_path=None, emo_alpha=1.0, emo_vector=None,
|
||||
use_emo_text=False, emo_text=None,
|
||||
interval_silence=200, max_text_tokens_per_segment=120,
|
||||
use_random=False):
|
||||
"""Generate a single segment and return (waveform, sample_rate)."""
|
||||
with torch.no_grad():
|
||||
tts.infer(
|
||||
spk_audio_prompt=ref_path,
|
||||
text=text,
|
||||
output_path=out_path,
|
||||
emo_audio_prompt=emo_path,
|
||||
emo_alpha=emo_alpha,
|
||||
emo_vector=emo_vector,
|
||||
use_emo_text=use_emo_text,
|
||||
emo_text=emo_text if (emo_text and emo_text.strip()) else None,
|
||||
interval_silence=interval_silence,
|
||||
max_text_tokens_per_segment=max_text_tokens_per_segment,
|
||||
use_random=use_random,
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
if not os.path.exists(out_path) or os.path.getsize(out_path) == 0:
|
||||
return None, None
|
||||
|
||||
wav, sr = torchaudio.load(out_path)
|
||||
return wav, sr
|
||||
|
||||
def generate(self, indextts_model, text, voice_preset="Female_HQ",
|
||||
reference_audio=None,
|
||||
emotion_audio=None,
|
||||
emo_alpha=1.0,
|
||||
happy=0.0, angry=0.0, sad=0.0, afraid=0.0,
|
||||
disgusted=0.0, melancholic=0.0, surprised=0.0, calm=0.0,
|
||||
use_emo_text=False, emo_text="",
|
||||
interval_silence=200, max_text_tokens_per_segment=120,
|
||||
use_random=False, seed=0):
|
||||
_ensure_indextts()
|
||||
|
||||
@@ -483,11 +595,20 @@ class AIIA_IndexTTS2_TTS:
|
||||
if emotion_audio is not None:
|
||||
emo_path = self._audio_to_wav_path(emotion_audio, prefix="indextts_emo_")
|
||||
|
||||
# --- Emotion vector (if any slider > 0) ---
|
||||
emo_vector = [happy, angry, sad, afraid, disgusted, melancholic, surprised, calm]
|
||||
has_emo_vector = any(v > 0.001 for v in emo_vector)
|
||||
if not has_emo_vector:
|
||||
emo_vector = None
|
||||
# --- Emotion vector from sliders (if any slider > 0) ---
|
||||
slider_emo_vector = [happy, angry, sad, afraid, disgusted, melancholic, surprised, calm]
|
||||
has_slider_emo = any(v > 0.001 for v in slider_emo_vector)
|
||||
if not has_slider_emo:
|
||||
slider_emo_vector = None
|
||||
|
||||
# --- Detect emotion tags in text ---
|
||||
emotion_segments = self._split_by_emotion_tags(text)
|
||||
has_multi_emotion = len(emotion_segments) > 1 or (len(emotion_segments) == 1 and emotion_segments[0][0] is not None)
|
||||
|
||||
if has_multi_emotion:
|
||||
print(f"[AIIA IndexTTS-2] Detected {len(emotion_segments)} emotion-tagged segment(s):")
|
||||
for i, (emo, seg) in enumerate(emotion_segments):
|
||||
print(f" Segment {i}: [{emo}] {seg[:50]}...")
|
||||
|
||||
# --- Output temp file ---
|
||||
out_fd, out_path = tempfile.mkstemp(suffix=".wav", prefix="indextts_out_")
|
||||
@@ -496,37 +617,100 @@ class AIIA_IndexTTS2_TTS:
|
||||
# --- Generate ---
|
||||
try:
|
||||
print(f"[AIIA IndexTTS-2] Generating: text='{text[:50]}...', ref={ref_path}")
|
||||
if emo_vector:
|
||||
print(f" Emotion vector: {emo_vector}, alpha={emo_alpha}")
|
||||
if emo_path:
|
||||
print(f" Emotion audio: {emo_path}")
|
||||
|
||||
# Temporarily re-apply patches for inference, then revert.
|
||||
with _transformers_patches(), torch.no_grad():
|
||||
tts.infer(
|
||||
spk_audio_prompt=ref_path,
|
||||
text=text,
|
||||
output_path=out_path,
|
||||
emo_audio_prompt=emo_path,
|
||||
if has_multi_emotion:
|
||||
# ===== Per-segment emotion generation =====
|
||||
all_waveforms = []
|
||||
final_sr = 22050 # will be overwritten by actual sr
|
||||
|
||||
for seg_idx, (seg_emotion, seg_text) in enumerate(emotion_segments):
|
||||
# Determine emotion vector for this segment
|
||||
seg_emo_vector = None
|
||||
seg_use_emo_text = False
|
||||
seg_emo_text = None
|
||||
seg_emo_path = emo_path # default: use global emotion audio
|
||||
|
||||
if seg_emotion:
|
||||
tag_lower = seg_emotion.lower().strip()
|
||||
if tag_lower in _EMOTION_TAG_TO_VECTOR:
|
||||
# Known tag → use mapped vector
|
||||
seg_emo_vector = list(_EMOTION_TAG_TO_VECTOR[tag_lower])
|
||||
seg_emo_path = None # tag overrides emotion audio
|
||||
print(f" [{seg_emotion}] → mapped vector: {seg_emo_vector}")
|
||||
else:
|
||||
# Unknown tag → fallback to QwenEmotion inference
|
||||
seg_use_emo_text = True
|
||||
seg_emo_text = seg_emotion
|
||||
seg_emo_path = None
|
||||
print(f" [{seg_emotion}] → unknown tag, falling back to QwenEmotion inference")
|
||||
else:
|
||||
# No tag on this segment → use slider vector or global settings
|
||||
seg_emo_vector = slider_emo_vector
|
||||
seg_use_emo_text = use_emo_text
|
||||
seg_emo_text = emo_text
|
||||
|
||||
print(f" Generating segment {seg_idx+1}/{len(emotion_segments)}: '{seg_text[:40]}...'")
|
||||
|
||||
wav, sr = self._infer_single_segment(
|
||||
tts, seg_text, ref_path, out_path,
|
||||
emo_path=seg_emo_path,
|
||||
emo_alpha=emo_alpha,
|
||||
emo_vector=seg_emo_vector,
|
||||
use_emo_text=seg_use_emo_text,
|
||||
emo_text=seg_emo_text,
|
||||
interval_silence=interval_silence,
|
||||
max_text_tokens_per_segment=max_text_tokens_per_segment,
|
||||
use_random=use_random,
|
||||
)
|
||||
|
||||
if wav is not None:
|
||||
all_waveforms.append(wav)
|
||||
final_sr = sr
|
||||
else:
|
||||
print(f" WARNING: Segment {seg_idx+1} produced no output, skipping.")
|
||||
|
||||
if not all_waveforms:
|
||||
print("[AIIA IndexTTS-2] WARNING: All segments produced no output. Returning silence.")
|
||||
silence = torch.zeros(1, 1, 22050)
|
||||
return ({"waveform": silence, "sample_rate": 22050},)
|
||||
|
||||
# Crossfade join all segments
|
||||
final_wav = self._crossfade_segments(all_waveforms, final_sr)
|
||||
final_wav = final_wav.unsqueeze(0) # (C, N) → (1, C, N)
|
||||
|
||||
total_duration = final_wav.shape[-1] / final_sr
|
||||
print(f"[AIIA IndexTTS-2] Generated {len(all_waveforms)} segments, total {total_duration:.2f}s at {final_sr}Hz")
|
||||
|
||||
return ({"waveform": final_wav, "sample_rate": final_sr},)
|
||||
|
||||
else:
|
||||
# ===== Single segment (original path) =====
|
||||
if slider_emo_vector:
|
||||
print(f" Emotion vector: {slider_emo_vector}, alpha={emo_alpha}")
|
||||
if emo_path:
|
||||
print(f" Emotion audio: {emo_path}")
|
||||
|
||||
wav, sr = self._infer_single_segment(
|
||||
tts, text, ref_path, out_path,
|
||||
emo_path=emo_path,
|
||||
emo_alpha=emo_alpha,
|
||||
emo_vector=emo_vector,
|
||||
emo_vector=slider_emo_vector,
|
||||
use_emo_text=use_emo_text,
|
||||
emo_text=emo_text,
|
||||
interval_silence=interval_silence,
|
||||
max_text_tokens_per_segment=max_text_tokens_per_segment,
|
||||
use_random=use_random,
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
# --- Read output wav → ComfyUI AUDIO format ---
|
||||
if not os.path.exists(out_path) or os.path.getsize(out_path) == 0:
|
||||
print("[AIIA IndexTTS-2] WARNING: Generation produced no output. Returning silence.")
|
||||
silence = torch.zeros(1, 1, 22050) # 1 second silence
|
||||
return ({"waveform": silence, "sample_rate": 22050},)
|
||||
if wav is None:
|
||||
print("[AIIA IndexTTS-2] WARNING: Generation produced no output. Returning silence.")
|
||||
silence = torch.zeros(1, 1, 22050)
|
||||
return ({"waveform": silence, "sample_rate": 22050},)
|
||||
|
||||
wav, sr = torchaudio.load(out_path)
|
||||
# wav shape: (channels, samples) → (1, channels, samples) for ComfyUI batch dim
|
||||
wav = wav.unsqueeze(0)
|
||||
wav = wav.unsqueeze(0) # (C, N) → (1, C, N)
|
||||
print(f"[AIIA IndexTTS-2] Generated {wav.shape[-1] / sr:.2f}s audio at {sr}Hz")
|
||||
|
||||
print(f"[AIIA IndexTTS-2] Generated {wav.shape[-1] / sr:.2f}s audio at {sr}Hz")
|
||||
|
||||
return ({"waveform": wav, "sample_rate": sr},)
|
||||
return ({"waveform": wav, "sample_rate": sr},)
|
||||
|
||||
finally:
|
||||
# Cleanup temp files
|
||||
|
||||
@@ -676,10 +676,15 @@ class AIIA_Podcast_Stitcher:
|
||||
duration_A = len(wav_A) / sr_A
|
||||
duration_B = len(wav_B) / sr_B
|
||||
|
||||
# 使用统一采样率
|
||||
# 使用统一采样率:如果 A 和 B 采样率不同,将 B 重采样到 A 的采样率
|
||||
sr = sr_A
|
||||
if sr_A != sr_B:
|
||||
print(f"{log} 警告: sr_A={sr_A} != sr_B={sr_B}, 使用 sr_A")
|
||||
print(f"{log} 采样率不一致: sr_A={sr_A} != sr_B={sr_B}, 将 B 重采样到 {sr_A}Hz")
|
||||
import torchaudio
|
||||
wav_B_tensor = torch.from_numpy(wav_B).unsqueeze(0) # (1, samples)
|
||||
wav_B_tensor = torchaudio.transforms.Resample(sr_B, sr_A)(wav_B_tensor)
|
||||
wav_B = wav_B_tensor.squeeze(0).numpy()
|
||||
sr_B = sr_A
|
||||
|
||||
# Forced Alignment 模式:对每个说话人做字级强制对齐
|
||||
fa_results_A = None
|
||||
|
||||
@@ -30,9 +30,16 @@ from transformers.cache_utils import (
|
||||
DynamicCache,
|
||||
EncoderDecoderCache,
|
||||
OffloadedCache,
|
||||
QuantizedCacheConfig,
|
||||
StaticCache,
|
||||
)
|
||||
# Patch for transformers > 4.36
|
||||
try:
|
||||
from transformers.cache_utils import QuantizedCacheConfig
|
||||
except ImportError:
|
||||
class QuantizedCacheConfig:
|
||||
def __init__(self, **kwargs):
|
||||
pass
|
||||
|
||||
from transformers.configuration_utils import PretrainedConfig
|
||||
from transformers.integrations.deepspeed import is_deepspeed_zero3_enabled
|
||||
from transformers.integrations.fsdp import is_fsdp_managed_module
|
||||
@@ -50,21 +57,45 @@ from transformers.utils import (
|
||||
)
|
||||
from transformers.generation.beam_constraints import DisjunctiveConstraint, PhrasalConstraint
|
||||
from transformers.generation.beam_search import BeamScorer, BeamSearchScorer, ConstrainedBeamSearchScorer
|
||||
from transformers.generation.candidate_generator import (
|
||||
AssistedCandidateGenerator,
|
||||
AssistedCandidateGeneratorDifferentTokenizers,
|
||||
CandidateGenerator,
|
||||
PromptLookupCandidateGenerator,
|
||||
_crop_past_key_values,
|
||||
_prepare_attention_mask,
|
||||
_prepare_token_type_ids,
|
||||
)
|
||||
|
||||
# Patch for transformers > 4.49
|
||||
try:
|
||||
from transformers.generation.candidate_generator import (
|
||||
AssistedCandidateGenerator,
|
||||
AssistedCandidateGeneratorDifferentTokenizers,
|
||||
CandidateGenerator,
|
||||
PromptLookupCandidateGenerator,
|
||||
_crop_past_key_values,
|
||||
_prepare_attention_mask,
|
||||
_prepare_token_type_ids,
|
||||
)
|
||||
except ImportError:
|
||||
from transformers.generation.candidate_generator import (
|
||||
AssistedCandidateGenerator,
|
||||
AssistedCandidateGeneratorDifferentTokenizers,
|
||||
CandidateGenerator,
|
||||
PromptLookupCandidateGenerator,
|
||||
_prepare_attention_mask,
|
||||
_prepare_token_type_ids,
|
||||
)
|
||||
def _crop_past_key_values(model, past_key_values, max_length):
|
||||
return past_key_values
|
||||
|
||||
from transformers.generation.configuration_utils import (
|
||||
NEED_SETUP_CACHE_CLASSES_MAPPING,
|
||||
QUANT_BACKEND_CLASSES_MAPPING,
|
||||
GenerationConfig,
|
||||
GenerationMode,
|
||||
)
|
||||
|
||||
# Patch for transformers mappings
|
||||
try:
|
||||
from transformers.generation.configuration_utils import (
|
||||
NEED_SETUP_CACHE_CLASSES_MAPPING,
|
||||
QUANT_BACKEND_CLASSES_MAPPING,
|
||||
)
|
||||
except ImportError:
|
||||
NEED_SETUP_CACHE_CLASSES_MAPPING = {}
|
||||
QUANT_BACKEND_CLASSES_MAPPING = {}
|
||||
|
||||
from transformers.generation.logits_process import (
|
||||
EncoderNoRepeatNGramLogitsProcessor,
|
||||
EncoderRepetitionPenaltyLogitsProcessor,
|
||||
@@ -1002,7 +1033,7 @@ class GenerationMixin:
|
||||
device=device,
|
||||
)
|
||||
)
|
||||
if generation_config.forced_decoder_ids is not None:
|
||||
if getattr(generation_config, "forced_decoder_ids", None) is not None:
|
||||
# TODO (sanchit): move this exception to GenerationConfig.validate() when TF & FLAX are aligned with PT
|
||||
raise ValueError(
|
||||
"You have explicitly specified `forced_decoder_ids`. Please remove the `forced_decoder_ids` argument "
|
||||
|
||||
@@ -32,7 +32,66 @@ import transformers
|
||||
|
||||
from indextts.gpt.transformers_generation_utils import GenerationMixin
|
||||
from indextts.gpt.transformers_modeling_utils import PreTrainedModel
|
||||
from transformers.modeling_utils import SequenceSummary
|
||||
|
||||
# SequenceSummary locally implemented as it was removed from transformers > 4.40
|
||||
class SequenceSummary(nn.Module):
|
||||
def __init__(self, config):
|
||||
super().__init__()
|
||||
|
||||
self.summary_type = getattr(config, "summary_type", "last")
|
||||
if self.summary_type == "cls_index":
|
||||
self.summary = nn.Identity()
|
||||
elif self.summary_type == "first":
|
||||
self.summary = nn.Identity()
|
||||
elif self.summary_type == "last":
|
||||
self.summary = nn.Identity()
|
||||
elif self.summary_type == "mean":
|
||||
self.summary = nn.Identity()
|
||||
else:
|
||||
self.summary = nn.Identity() # Fallback
|
||||
|
||||
if getattr(config, "summary_use_proj", True):
|
||||
self.summary_proj = nn.Linear(config.hidden_size, config.hidden_size)
|
||||
else:
|
||||
self.summary_proj = nn.Identity()
|
||||
|
||||
if getattr(config, "summary_activation", None) == "tanh":
|
||||
self.activation = nn.Tanh()
|
||||
else:
|
||||
self.activation = nn.Identity()
|
||||
|
||||
self.last_dropout = nn.Dropout(getattr(config, "summary_last_dropout", 0.0))
|
||||
|
||||
def forward(self, hidden_states: torch.FloatTensor, cls_index: Optional[torch.LongTensor] = None) -> torch.FloatTensor:
|
||||
if self.summary_type == "last":
|
||||
output = hidden_states[:, -1, :]
|
||||
elif self.summary_type == "first":
|
||||
output = hidden_states[:, 0, :]
|
||||
elif self.summary_type == "mean":
|
||||
output = hidden_states.mean(dim=1)
|
||||
elif self.summary_type == "cls_index":
|
||||
if cls_index is None:
|
||||
output = hidden_states[:, -1, :]
|
||||
else:
|
||||
# cls_index: [batch_size] or [batch_size, num_choices] (if flattened)
|
||||
if cls_index.dim() > 1:
|
||||
cls_index = cls_index.flatten()
|
||||
|
||||
batch_size = hidden_states.shape[0]
|
||||
if cls_index.shape[0] != batch_size:
|
||||
# Mismatch, fallback to last
|
||||
output = hidden_states[:, -1, :]
|
||||
else:
|
||||
output = hidden_states[torch.arange(batch_size, device=hidden_states.device), cls_index]
|
||||
else:
|
||||
output = hidden_states[:, -1, :]
|
||||
|
||||
output = self.summary_proj(output)
|
||||
output = self.activation(output)
|
||||
output = self.last_dropout(output)
|
||||
|
||||
return output
|
||||
|
||||
|
||||
from transformers.modeling_attn_mask_utils import _prepare_4d_attention_mask_for_sdpa, _prepare_4d_causal_attention_mask_for_sdpa
|
||||
from transformers.modeling_outputs import (
|
||||
|
||||
@@ -35,6 +35,25 @@ from transformers import SeamlessM4TFeatureExtractor
|
||||
import random
|
||||
import torch.nn.functional as F
|
||||
|
||||
# --- Patch for transformers > 4.40 ---
|
||||
# apply_chunking_to_forward was removed
|
||||
import transformers.modeling_utils
|
||||
if not hasattr(transformers.modeling_utils, "apply_chunking_to_forward"):
|
||||
def _chunking(forward_fn, chunk_size, *tensors, **kw):
|
||||
return forward_fn(*tensors, **kw)
|
||||
transformers.modeling_utils.apply_chunking_to_forward = _chunking
|
||||
|
||||
# GenerationConfig cleanup for newer transformers
|
||||
from transformers import GenerationConfig
|
||||
# Remove deprecated attributes if they exist to prevent warnings/errors
|
||||
if hasattr(GenerationConfig, "forced_decoder_ids"):
|
||||
pass # valid
|
||||
# But sometimes we might need to remove it from kwargs if passed inappropriately?
|
||||
# The error "forced_decoder_ids is deprecated" usually comes from passing it to __init__
|
||||
# We can't easily patch the class __init__, but we can patch where it's used if we find it.
|
||||
# For now, the apply_chunking_to_forward is the critical missing piece for compilation/startup.
|
||||
# -------------------------------------
|
||||
|
||||
class IndexTTS2:
|
||||
def __init__(
|
||||
self, cfg_path="checkpoints/config.yaml", model_dir="checkpoints", use_fp16=False, device=None,
|
||||
@@ -164,9 +183,19 @@ class IndexTTS2:
|
||||
self.bigvgan = self.bigvgan.to(self.device)
|
||||
self.bigvgan.remove_weight_norm()
|
||||
self.bigvgan.eval()
|
||||
print(">> bigvgan weights restored from:", bigvgan_name)
|
||||
try:
|
||||
v_cfg = transformers.AutoConfig.from_pretrained(bigvgan_name, trust_remote_code=True)
|
||||
if hasattr(v_cfg, "sampling_rate"):
|
||||
self.sampling_rate = v_cfg.sampling_rate
|
||||
else:
|
||||
self.sampling_rate = 22050
|
||||
except Exception as e:
|
||||
print(f">> Failed to load BigVGAN config for sampling_rate: {e}, using default 22050")
|
||||
self.sampling_rate = 22050
|
||||
print(f"Loaded BigVGAN sampling_rate: {self.sampling_rate}")
|
||||
|
||||
self.bpe_path = os.path.join(self.model_dir, self.cfg.dataset["bpe_model"])
|
||||
|
||||
self.normalizer = TextNormalizer(enable_glossary=True)
|
||||
self.normalizer.load()
|
||||
print(">> TextNormalizer loaded")
|
||||
@@ -325,7 +354,10 @@ class IndexTTS2:
|
||||
|
||||
def _load_and_cut_audio(self,audio_path,max_audio_length_seconds,verbose=False,sr=None):
|
||||
if not sr:
|
||||
audio, sr = librosa.load(audio_path)
|
||||
# Use native sampling rate! Do not let librosa default to 22050 implicitly.
|
||||
audio, sr = librosa.load(audio_path, sr=None)
|
||||
if verbose:
|
||||
print(f">> Loaded audio {audio_path} at native SR: {sr}Hz")
|
||||
else:
|
||||
audio, _ = librosa.load(audio_path,sr=sr)
|
||||
audio = torch.tensor(audio).unsqueeze(0)
|
||||
@@ -524,7 +556,7 @@ class IndexTTS2:
|
||||
num_beams = generation_kwargs.pop("num_beams", 3)
|
||||
repetition_penalty = generation_kwargs.pop("repetition_penalty", 10.0)
|
||||
max_mel_tokens = generation_kwargs.pop("max_mel_tokens", 1500)
|
||||
sampling_rate = 22050
|
||||
sampling_rate = self.sampling_rate
|
||||
|
||||
wavs = []
|
||||
gpt_gen_time = 0
|
||||
@@ -686,7 +718,15 @@ class IndexTTS2:
|
||||
print(f">> RTF: {(end_time - start_time) / wav_length:.4f}")
|
||||
|
||||
# save audio
|
||||
wav = wav.cpu() # to cpu
|
||||
wav = wav.cpu().float() # ensure cpu float
|
||||
|
||||
# Normalize to prevent clipping (fixes distortion)
|
||||
# User requested Float32 to avoid precision loss.
|
||||
max_val = wav.abs().max()
|
||||
if max_val > 0.99:
|
||||
wav = wav / max_val * 0.99
|
||||
|
||||
# Use simple float saving for max quality
|
||||
if output_path:
|
||||
# 直接保存音频到指定路径中
|
||||
if os.path.isfile(output_path):
|
||||
@@ -694,7 +734,8 @@ class IndexTTS2:
|
||||
print(">> remove old wav file:", output_path)
|
||||
if os.path.dirname(output_path) != "":
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
torchaudio.save(output_path, wav.float(), sampling_rate)
|
||||
|
||||
torchaudio.save(output_path, wav, sampling_rate)
|
||||
print(">> wav file saved to:", output_path)
|
||||
if stream_return:
|
||||
return None
|
||||
@@ -703,7 +744,7 @@ class IndexTTS2:
|
||||
if stream_return:
|
||||
return None
|
||||
# 返回以符合Gradio的格式要求
|
||||
wav_data = (wav * 32767).clamp(-32767, 32767).type(torch.int16)
|
||||
wav_data = (wav * 32767).to(torch.int16)
|
||||
wav_data = wav_data.numpy().T
|
||||
yield (sampling_rate, wav_data)
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "aiia"
|
||||
description = "Advanced AI Audio/Video toolkit for ComfyUI. Features Multi-Role Podcast/Dialogue Generation, High-Fidelity Voice Cloning (CosyVoice/VibeVoice), TTS, Media Management, and efficient Video tools."
|
||||
version = "1.14.3"
|
||||
version = "1.15.0"
|
||||
license = {file = "LICENSE"}
|
||||
readme = "README.md"
|
||||
authors = [
|
||||
|
||||
+19
-13
@@ -164,24 +164,30 @@ for use_fp16 in [True, False]:
|
||||
print("No wavs generated via stream!")
|
||||
continue
|
||||
|
||||
# The model output in stream_return is now raw float (normalized internally in infer_v2 if we used that path,
|
||||
# but here we get the raw tensor from generator.
|
||||
|
||||
full_wav = torch.cat(wavs, dim=1) if len(wavs) > 1 else wavs[0]
|
||||
|
||||
# Save as float (normalized)
|
||||
# The model output seems to be already scaled to 32767 in `infer_v2.py` line 664?
|
||||
# wav = torch.clamp(32767 * wav, ...)
|
||||
# Wait, `infer_v2.py` does that inside the loop.
|
||||
# So `wavs` collected here are ALREADY multiplied by 32767.
|
||||
# infer_v2.py normalization happens inside the `if output_path` block or yielded.
|
||||
# Wait, if we use `stream_return`, `infer_v2` yields:
|
||||
# 1. `wav.cpu().float()` (raw chunk)
|
||||
# 2. `(sampling_rate, wav_data_numpy)` (int16 version for Gradio)
|
||||
|
||||
# So we should divide by 32768.0 to get back to float [-1, 1]
|
||||
full_wav_float = full_wav.float() / 32768.0
|
||||
# We collected raw chunks. They are NOT normalized if they come from the loop `yield wav.cpu()`.
|
||||
# We need to normalize them manually here to MATCH what infer_v2 does when saving file.
|
||||
|
||||
full_wav_float = full_wav.float()
|
||||
max_val = full_wav_float.abs().max()
|
||||
if max_val > 0.99:
|
||||
full_wav_float = full_wav_float / max_val * 0.99
|
||||
|
||||
import torchaudio
|
||||
torchaudio.save(output_path.replace(".wav", "_norm.wav"), full_wav_float, 24000)
|
||||
print(f"Saved normalized float wav: {output_path.replace('.wav', '_norm.wav')}")
|
||||
|
||||
# Also save as int16 as original
|
||||
torchaudio.save(output_path, full_wav.to(torch.int16), 24000)
|
||||
print(f"Saved int16 wav: {output_path}")
|
||||
# Save as float32 (User requested format)
|
||||
torchaudio.save(output_path, full_wav_float, 22050) # We assume 22050 for test or explicit SR?
|
||||
# Ideally we should use the model's SR, but we can't easily access tts.sampling_rate here without more code.
|
||||
# Let's assume 22050 as that is what we expect BigVGAN to be.
|
||||
print(f"Saved float32 wav: {output_path}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Inference failed: {e}")
|
||||
|
||||
Reference in New Issue
Block a user