import json import os import librosa import numpy as np import torch import torchaudio from typing import List, Union, Optional from tn.chinese.normalizer import Normalizer as ZhNormalizer from tn.english.normalizer import Normalizer as EnNormalizer from langdetect import detect as classify_language import pyloudnorm as pyln import folder_paths import gc import re import sys current_dir = os.path.dirname(os.path.abspath(__file__)) if current_dir not in sys.path: sys.path.append(current_dir) from tts.modules.ar_dur.commons.nar_tts_modules import LengthRegulator from tts.frontend_function import g2p, align, make_dur_prompt, dur_pred, prepare_inputs_for_dit from tts.utils.audio_utils.io import convert_to_wav_bytes, combine_audio_segments from tts.utils.commons.ckpt_utils import load_ckpt from tts.utils.commons.hparams import set_hparams, hparams from tts.utils.text_utils.text_encoder import TokenTextEncoder from tts.utils.text_utils.split_text import chunk_text_chinese, chunk_text_english, chunk_text_chinesev2 from tts.utils.commons.hparams import hparams, set_hparams models_dir = folder_paths.models_dir model_path = os.path.join(models_dir, "TTS") speakers_dir = os.path.join(model_path, "speakers") cache_dir = folder_paths.get_temp_directory() def get_all_files( root_dir: str, return_type: str = "list", extensions: Optional[List[str]] = None, exclude_dirs: Optional[List[str]] = None, relative_path: bool = False ) -> Union[List[str], dict]: """ 递归获取目录下所有文件路径 :param root_dir: 要遍历的根目录 :param return_type: 返回类型 - "list"(列表) 或 "dict"(按目录分组) :param extensions: 可选的文件扩展名过滤列表 (如 ['.py', '.txt']) :param exclude_dirs: 要排除的目录名列表 (如 ['__pycache__', '.git']) :param relative_path: 是否返回相对路径 (相对于root_dir) :return: 文件路径列表或字典 """ file_paths = [] file_dict = {} # 规范化目录路径 root_dir = os.path.normpath(root_dir) for dirpath, dirnames, filenames in os.walk(root_dir): # 处理排除目录 if exclude_dirs: dirnames[:] = [d for d in dirnames if d not in exclude_dirs] current_files = [] for filename in filenames: # 扩展名过滤 if extensions: if not any(filename.lower().endswith(ext.lower()) for ext in extensions): continue # 构建完整路径 full_path = os.path.join(dirpath, filename) # 处理相对路径 if relative_path: full_path = os.path.relpath(full_path, root_dir) current_files.append(full_path) if return_type == "dict": # 使用相对路径或绝对路径作为键 dict_key = os.path.relpath(dirpath, root_dir) if relative_path else dirpath if current_files: file_dict[dict_key] = current_files else: file_paths.extend(current_files) return file_dict if return_type == "dict" else file_paths def get_speakers(): if not os.path.exists(speakers_dir): os.makedirs(speakers_dir, exist_ok=True) return [] speakers = get_all_files(speakers_dir, extensions=[".wav", ".mp3", ".flac", ".mp4", ".WAV", ".MP3", ".FLAC", ".MP4"], relative_path=True) return speakers class MegaTTS3DiTInfer(): def __init__( self, device=None, ckpt_root=os.path.join(model_path, "MegaTTS3"), dit_exp_name='diffusion_transformer', frontend_exp_name='aligner_lm', wavvae_exp_name='wavvae', dur_ckpt_path='duration_lm', g2p_exp_name='g2p', precision=torch.float16, **kwargs ): self.sr = 24000 self.fm = 8 if device is None: device = 'cuda' if torch.cuda.is_available() else 'cpu' self.device = device self.precision = precision # build models self.dit_exp_name = os.path.join(ckpt_root, dit_exp_name) self.frontend_exp_name = os.path.join(ckpt_root, frontend_exp_name) self.wavvae_exp_name = os.path.join(ckpt_root, wavvae_exp_name) self.dur_exp_name = os.path.join(ckpt_root, dur_ckpt_path) self.g2p_exp_name = os.path.join(ckpt_root, g2p_exp_name) self.build_model(self.device) # init text normalizer self.zh_normalizer = ZhNormalizer(overwrite_cache=False, remove_erhua=False, remove_interjections=False) self.en_normalizer = EnNormalizer(overwrite_cache=False) # loudness meter self.loudness_meter = pyln.Meter(self.sr) self.ph_ref = None self.tone_ref = None self.mel2ph_ref = None self.vae_latent = None self.ctx_dur_tokens = None self.incremental_state_dur_prompt = None self.audio_bytes = None def clean(self): import gc self.dur_model = None self.dit= None self.g2p_model = None self.wavvae_en = None self.wavvae_de = None self.aligner_lm = None self.audio_bytes = None self.ph_ref = None self.tone_ref = None self.mel2ph_ref = None self.vae_latent = None self.ctx_dur_tokens = None self.incremental_state_dur_prompt = None gc.collect() torch.cuda.empty_cache() def build_model(self, device): set_hparams(exp_name=self.dit_exp_name, print_hparams=False) ''' Load Dict ''' current_dir = os.path.dirname(os.path.abspath(__file__)) ling_dict = json.load(open(f"{current_dir}/tts/utils/text_utils/dict.json", encoding='utf-8-sig')) self.ling_dict = {k: TokenTextEncoder(None, vocab_list=ling_dict[k], replace_oov='') for k in ['phone', 'tone']} self.token_encoder = token_encoder = self.ling_dict['phone'] ph_dict_size = len(token_encoder) ''' Load Duration LM ''' from tts.modules.ar_dur.ar_dur_predictor import ARDurPredictor hp_dur_model = self.hp_dur_model = set_hparams(f'{self.dur_exp_name}/config.yaml', global_hparams=False) hp_dur_model['frames_multiple'] = hparams['frames_multiple'] self.dur_model = ARDurPredictor( hp_dur_model, hp_dur_model['dur_txt_hs'], hp_dur_model['dur_model_hidden_size'], hp_dur_model['dur_model_layers'], ph_dict_size, hp_dur_model['dur_code_size'], use_rot_embed=hp_dur_model.get('use_rot_embed', False)) self.length_regulator = LengthRegulator() load_ckpt(self.dur_model, f'{self.dur_exp_name}', 'dur_model') self.dur_model.eval() self.dur_model.to(device) ''' Load Diffusion Transformer ''' from tts.modules.llm_dit.dit import Diffusion self.dit = Diffusion() load_ckpt(self.dit, f'{self.dit_exp_name}', 'dit', strict=False) self.dit.eval() self.dit.to(device) self.cfg_mask_token_phone = 302 - 1 self.cfg_mask_token_tone = 32 - 1 ''' Load Frontend LM ''' from tts.modules.aligner.whisper_small import Whisper self.aligner_lm = Whisper() load_ckpt(self.aligner_lm, f'{self.frontend_exp_name}', 'model') self.aligner_lm.eval() self.aligner_lm.to(device) self.kv_cache = None self.hooks = None ''' Load G2P LM''' from transformers import AutoTokenizer, AutoModelForCausalLM g2p_tokenizer = AutoTokenizer.from_pretrained(self.g2p_exp_name, padding_side="right") g2p_tokenizer.padding_side = "right" self.g2p_model = AutoModelForCausalLM.from_pretrained(self.g2p_exp_name).eval().to(device) self.g2p_tokenizer = g2p_tokenizer self.speech_start_idx = g2p_tokenizer.encode('')[0] ''' Wav VAE ''' self.hp_wavvae = hp_wavvae = set_hparams(f'{self.wavvae_exp_name}/config.yaml', global_hparams=False) from tts.modules.wavvae.decoder.wavvae_v3 import WavVAE_V3 self.wavvae_en = WavVAE_V3(hparams=hp_wavvae) self.wavvae_de = WavVAE_V3(hparams=hp_wavvae) if os.path.exists(f'{self.wavvae_exp_name}/model_only_last.ckpt'): load_ckpt(self.wavvae_en, f'{self.wavvae_exp_name}/model_only_last.ckpt', 'model_gen', strict=True) self.has_vae_encoder = True self.wavvae_en.eval() self.wavvae_en.to(device) else: load_ckpt(self.wavvae_de, f'{self.wavvae_exp_name}/decoder.ckpt', 'model_gen', strict=False) self.has_vae_encoder = False self.wavvae_de.eval() self.wavvae_de.to(device) self.vae_stride = hp_wavvae.get('vae_stride', 4) self.hop_size = hp_wavvae.get('hop_size', 4) def preprocess(self, audio_bytes, latent_file=None, topk_dur=1, **kwargs): if self.audio_bytes != audio_bytes: self.audio_bytes = audio_bytes wav_bytes = convert_to_wav_bytes(audio_bytes) ''' Load wav ''' wav, _ = librosa.core.load(wav_bytes, sr=self.sr) # Pad wav if necessary ws = hparams['win_size'] if len(wav) % ws < ws - 1: wav = np.pad(wav, (0, ws - 1 - (len(wav) % ws)), mode='constant', constant_values=0.0).astype(np.float32) wav = np.pad(wav, (0, 12000), mode='constant', constant_values=0.0).astype(np.float32) self.loudness_prompt = self.loudness_meter.integrated_loudness(wav.astype(float)) ''' obtain alignments with aligner_lm ''' ph_ref, tone_ref, mel2ph_ref = align(self, wav) self.kv_cache = None self.hooks = None with torch.inference_mode(): ''' Forward WaveVAE to obtain: prompt latent ''' if self.has_vae_encoder: if latent_file is None: wav = torch.FloatTensor(wav)[None].to(self.device) vae_latent = self.wavvae_en.encode_latent(wav) else: vae_latent = torch.from_numpy(np.load(latent_file)).to(self.device) vae_latent = vae_latent[:, :mel2ph_ref.size(1)//4] else: assert latent_file is not None, "WaveVAE encode model does not exist, an npy file must be provided!!!" vae_latent = torch.from_numpy(np.load(latent_file)).to(self.device) vae_latent = vae_latent[:, :mel2ph_ref.size(1)//4] ''' Duration Prompting ''' self.dur_model.hparams["infer_top_k"] = topk_dur if topk_dur > 1 else None incremental_state_dur_prompt, ctx_dur_tokens = make_dur_prompt(self, mel2ph_ref, ph_ref, tone_ref) self.ph_ref = ph_ref.to(self.device) self.tone_ref = tone_ref.to(self.device) self.mel2ph_ref = mel2ph_ref.to(self.device) self.vae_latent = vae_latent.to(self.device) self.ctx_dur_tokens = ctx_dur_tokens.to(self.device) self.incremental_state_dur_prompt = incremental_state_dur_prompt def forward(self, texts, time_step, p_w, t_w, dur_disturb=0.1, dur_alpha=1.0, **kwargs): with torch.inference_mode(): ''' Generating ''' waveforms = [] for input_text in texts: wav_pred_ = [] language_type = classify_language(input_text) if language_type == 'en': input_text = self.en_normalizer.normalize(input_text) text_segs = chunk_text_english(input_text, max_chars=130) else: input_text = self.zh_normalizer.normalize(input_text) text_segs = chunk_text_chinesev2(input_text, limit=60) for seg_i, text in enumerate(text_segs): ''' G2P ''' ph_pred, tone_pred = g2p(self, text) ''' Duration Prediction ''' mel2ph_pred = dur_pred(self, self.ctx_dur_tokens, self.incremental_state_dur_prompt, ph_pred, tone_pred, seg_i, dur_disturb, dur_alpha, is_first=seg_i==0, is_final=seg_i==len(text_segs)-1) inputs = prepare_inputs_for_dit(self, self.mel2ph_ref, mel2ph_pred, self.ph_ref, self.tone_ref, ph_pred, tone_pred, self.vae_latent) # Speech dit inference with torch.cuda.amp.autocast(dtype=self.precision, enabled=True): x = self.dit.inference(inputs, timesteps=time_step, seq_cfg_w=[p_w, t_w]).float() # WavVAE decode x[:, :self.vae_latent.size(1)] = self.vae_latent if self.has_vae_encoder: wav_pred = self.wavvae_en.decode(x)[0,0].to(torch.float32) else: wav_pred = self.wavvae_de.decode(x)[0,0].to(torch.float32) ''' Post-processing ''' # Trim prompt wav wav_pred = wav_pred[self.vae_latent.size(1)*self.vae_stride*self.hop_size:].cpu().numpy() # Norm generated wav to prompt wav's level meter = pyln.Meter(self.sr) # create BS.1770 meter loudness_pred = self.loudness_meter.integrated_loudness(wav_pred.astype(float)) wav_pred = pyln.normalize.loudness(wav_pred, loudness_pred, self.loudness_prompt) if np.abs(wav_pred).max() >= 1: wav_pred = wav_pred / np.abs(wav_pred).max() * 0.95 # Apply hamming window wav_pred_.append(wav_pred) gc.collect() torch.cuda.empty_cache() wav_pred = combine_audio_segments(wav_pred_, sr=self.sr).astype(np.float32) waveform = torch.tensor(wav_pred) waveforms.append(waveform.cpu()) return torch.cat(waveforms, dim=0), self.sr class MegaTTS3SpeakersPreview: @classmethod def INPUT_TYPES(s): speakers = get_speakers() return { "required": {"speaker":(speakers,),},} RETURN_TYPES = ("AUDIO", "STRING", ) RETURN_NAMES = ("audio", "npy_file", ) FUNCTION = "preview" CATEGORY = "🎤MW/MW-MegaTTS3" def preview(self, speaker): wav_path = os.path.join(speakers_dir, speaker) latent_file = wav_path.rsplit('.', 1)[0] + '.npy' if not os.path.exists(latent_file): latent_file = "" waveform, sample_rate = torchaudio.load(wav_path) waveform = waveform.unsqueeze(0) output_audio = { "waveform": waveform, "sample_rate": sample_rate } return (output_audio, latent_file) def cache_audio_tensor( cache_dir, audio_tensor: torch.Tensor, sample_rate: int, filename_prefix: str = "cached_audio_", audio_format: Optional[str] = ".wav" ) -> str: import tempfile try: with tempfile.NamedTemporaryFile( prefix=filename_prefix, suffix=audio_format, dir=cache_dir, delete=False ) as tmp_file: temp_filepath = tmp_file.name torchaudio.save(temp_filepath, audio_tensor, sample_rate) return temp_filepath except Exception as e: raise Exception(f"Error caching audio tensor: {e}") def statistical_compare(tensor1, tensor2): """通过统计特征快速比较""" stats1 = { 'mean': tensor1.mean(), 'std': tensor1.std(), 'max': tensor1.max(), 'min': tensor1.min() } stats2 = { 'mean': tensor2.mean(), 'std': tensor2.std(), 'max': tensor2.max(), 'min': tensor2.min() } return all(torch.allclose(stats1[k], stats2[k], rtol=1e-3) for k in stats1) INFER_INS_CACHE = None class MegaTTS3Run: def __init__(self): self.resource_context = None self.audio_tensor = None self.audio_prompt = None @classmethod def INPUT_TYPES(s): return { "required": { "audio": ("AUDIO",), "text": ("STRING", {"forceInput": True}), "time_step": ("INT", {"default": 32, "min": 1,}), "p_w": ("FLOAT", {"default":1.6, "min": 0.1,}), "t_w": ("FLOAT", {"default": 2.5, "min": 0.1,}), "unload_model": ("BOOLEAN", {"default": True}), }, "optional": { "dialogue_audio_s2":("AUDIO",), "audio_npy_file": ("STRING", {"forceInput": True, "tooltip": "No `npy_file` will use VAE to encode audio. 不提供 .npy 文件, 将使用 WaveVAE 编码音频"}), "audio_s2_npy_file": ("STRING", {"forceInput": True, "tooltip": "No `npy_file` will use VAE to encode audio. 不提供 .npy 文件, 将使用 WaveVAE 编码音频"}), } } RETURN_TYPES = ("AUDIO",) RETURN_NAMES = ("audio",) FUNCTION = "clone" CATEGORY = "🎤MW/MW-MegaTTS3" def clone(self, audio, text, time_step, p_w, t_w, unload_model, audio_npy_file=None, dialogue_audio_s2=None, audio_s2_npy_file=None): if not os.path.exists(os.path.join(model_path, "MegaTTS3", 'wavvae', 'model_only_last.ckpt')): print("WaveVAE encode model does not exist, an npy file must be provided!!!") waveform = audio["waveform"].squeeze(0) global INFER_INS_CACHE if INFER_INS_CACHE is None: INFER_INS_CACHE = MegaTTS3DiTInfer() latent_file = audio_npy_file if audio_npy_file else None try: import gc if dialogue_audio_s2 is None: # 只有音频改变时, 才重新预处理 if self.audio_tensor is None or self.audio_prompt is None or statistical_compare(self.audio_tensor, waveform) == False: self.audio_tensor = waveform self.audio_prompt = cache_audio_tensor(cache_dir, waveform, audio["sample_rate"]) texts = [i.strip() for i in re.split(r'\n\s*\n', text.strip()) if i.strip()] with open(self.audio_prompt, 'rb') as file: file_content = file.read() INFER_INS_CACHE.preprocess(file_content, latent_file=latent_file) del file_content gc.collect() torch.cuda.empty_cache() waveform, sr = INFER_INS_CACHE.forward(texts=texts, time_step=time_step, p_w=p_w, t_w=t_w) gc.collect() torch.cuda.empty_cache() else: latent_file_2 = audio_s2_npy_file if audio_s2_npy_file else None audio_1 = cache_audio_tensor(cache_dir, waveform, audio["sample_rate"]) audio_2 = cache_audio_tensor(cache_dir, dialogue_audio_s2["waveform"].squeeze(0), dialogue_audio_s2["sample_rate"]) with open(audio_1, 'rb') as file: file_content_1 = file.read() with open(audio_2, 'rb') as file: file_content_2 = file.read() gc.collect() torch.cuda.empty_cache() ress = [] for t, a, n in self.get_speaker_text_audio(text, audio_1, audio_2): texts = [i.strip() for i in re.split(r'\n\s*\n', t.strip()) if i.strip()] if a == audio_1: INFER_INS_CACHE.preprocess(file_content_1, latent_file=latent_file) res_sub, sr = INFER_INS_CACHE.forward(texts=texts, time_step=time_step, p_w=p_w, t_w=t_w) ress.append([res_sub, n]) else: INFER_INS_CACHE.preprocess(file_content_2, latent_file=latent_file_2) res_sub, sr = INFER_INS_CACHE.forward(texts=texts, time_step=time_step, p_w=p_w, t_w=t_w) ress.append([res_sub, n]) del file_content_1 del file_content_2 gc.collect() torch.cuda.empty_cache() waveform = torch.cat(list(zip(*sorted(ress, key=lambda x: x[1])))[0], dim=0) except Exception as e: if unload_model: import gc INFER_INS_CACHE.clean() INFER_INS_CACHE = None self.resource_context = None gc.collect() torch.cuda.empty_cache() raise e if unload_model: import gc INFER_INS_CACHE.clean() INFER_INS_CACHE = None self.resource_context = None gc.collect() torch.cuda.empty_cache() return ({"waveform": waveform.unsqueeze(0).unsqueeze(0), "sample_rate": sr},) def get_speaker_text_audio(self, text, audio_1, audio_2): pattern = r'(\[s?S?1\]|\[s?S?2\])\s*([\s\S]*?)(?=\[s?S?[12]\]|$)' matches = re.findall(pattern, text) if len(matches) == 0: raise ValueError("No speaker tags found in the text: [S1]... [S2]...") labels = [] contents = [] audios = [] for label, content in matches: labels.append(label) contents.append(content) audios = [ audio_1 if i.lower() == '[s1]' else audio_2 for i in labels ] return sorted(zip(contents, audios, range(len(contents))), key=lambda x: x[1]) class MultiLinePromptMG: @classmethod def INPUT_TYPES(cls): return { "required": { "multi_line_prompt": ("STRING", { "multiline": True, "default": ""}), }, } CATEGORY = "🎤MW/MW-MegaTTS3" RETURN_TYPES = ("STRING",) RETURN_NAMES = ("text",) FUNCTION = "promptgen" def promptgen(self, multi_line_prompt: str): return (multi_line_prompt.strip(),) NODE_CLASS_MAPPINGS = { "MegaTTS3SpeakersPreview": MegaTTS3SpeakersPreview, "MegaTTS3Run": MegaTTS3Run, "MultiLinePromptMG": MultiLinePromptMG, } NODE_DISPLAY_NAME_MAPPINGS = { "MegaTTS3SpeakersPreview": "MegaTTS3 Speakers Preview", "MegaTTS3Run": "MegaTTS3 Run", "MultiLinePromptMG": "Multi Line Text", }